Cache : PREWARM prépare le prochain tour pendant que l'utilisateur lit, en opt-in

Une compaction, un dernier message rendu autrement au tour suivant ou une
tâche planifiée qui a pris le slot laissent au message suivant un recalcul
inévitable : des secondes sur un 27B, des dizaines sur un MoE aux experts en
RAM, avant le premier mot. Nouvelle clé PREWARM (off par défaut) : dès que le
moteur local est libre, Loki lui envoie la requête du prochain tour suivie
d'un message utilisateur « . », max_tokens 1, sans flux. Le moteur calcule le
préfixe et pose un point de reprise au début de ce message ; le vrai tour ne
calcule plus que le sien. Sans la clé, aucune requête de plus (testé).

- assemblage partagé, contenu vivant : turnViewDry (même code que turnView,
  sans rien toucher à la discussion, décision PROJ_SNAPSHOT via
  projSnapDecide), wireMessages, turnReasoning et buildChatPayload, extraits
  de runChat ; corps identique à l'octet près à celui d'avant (matrice
  d'options et de modes, testée contre une copie de l'ancien code)
- jamais devant un vrai travail : toute requête de Loki l'annule au départ
  (compteur de llm_slots.go, sous son verrou), sauf un tour de chat dont les
  messages sérialisés, outils et arguments du gabarit prolongent exactement
  le préfixe préchauffé
- moteur local, un seul slot (/props total_slots), pas pendant un tour, une
  tâche, un bench, un travail annexe ni une requête en vol ; un seul à la
  fois ; pas si le prochain tour compactera, ni à moins de 10 min de minuit
- requête brute : ni compaction, ni relance, ni effort appris, ni CtxUsed,
  ni stats ; erreurs lâchées (une ligne de journal par statut) ; rien persisté
- déclencheurs : fin de tour sans file d'attente, fin de tâche (projet forcé
  levé, slot effacé) ; full = aussi changement de discussion après 3 s
- capacités du dernier tour gardées en mémoire par discussion ; mode Code =
  rôle d'un message ordinaire, une demande de plan diverge et annule
- télémétrie kind=prewarm ; la vérification du cache RAM après une tâche se
  fait sur lui

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 09:01:57 +02:00
1 parent 4932999d5c
commit 630e84cc08
14 files changed
+1186 -86

No files matched your search

+12 -7
View File
@@ -512,20 +512,19 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
if strings.TrimSpace(prompt) == "" {
prompt = "Prends-en connaissance."
}
askedCaps := caps
// Mode code : fige le rôle du tour (plan demandé → planner, sinon builder).
// La détection de bascule (puce « passer en mode Code ? ») est émise après
// la bulle utilisateur, plus bas.
if caps.Code && caps.Role == "" {
caps.Role = "builder"
if wantsPlan(text) {
caps.Role = "planner"
}
}
caps = withTurnRole(caps, text)
// Content = simple texte d'ordinaire ; format multimodal (texte + images) quand
// la vision est active et qu'une pièce jointe est une image (userMessageContent).
c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)})
epoch := c.epoch
c.mu.Unlock()
// Capacités demandées pour ce tour, retenues en mémoire (jamais persistées)
// pour préchauffer le suivant (PREWARM, chat_prewarm.go).
prewarmNoteCaps(convActiveID(), askedCaps)
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite
// (sur disque quelques ms plus tard, voir persistAsync) : si le process
@@ -763,9 +762,15 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas
// de notification après un « stop » (retour ci-dessus) : l'utilisateur
// est là et a coupé volontairement.
if !c.startQueuedIfAny() && hasPushSubs() {
started := c.startQueuedIfAny()
if !started && hasPushSubs() {
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
}
// Plus rien ne suit : le prochain tour peut être préparé pendant que
// l'utilisateur lit (PREWARM, off par défaut — sans la clé, rien ne part).
if !started {
c.prewarmKick(prewarmTurnEnd)
}
}()
// Télémétrie : tout ce que ce tour envoie au moteur (étapes, compaction,
// sous-agents, vérification) est rattaché à la discussion active.
+438
View File
@@ -0,0 +1,438 @@
package loki
// chat_prewarm.go — PREWARM : préparer le prochain tour pendant que
// l'utilisateur lit.
//
// Pourquoi. Certains recalculs sont inévitables : le prompt réécrit par une
// compaction (12 à 20k jetons), le dernier message assistant rendu autrement
// au tour suivant (réflexion retirée par le gabarit, raisonnement jamais
// renvoyé), la discussion reprise après une tâche planifiée qui a pris le slot.
// Sans la clé, ils tombent sur le premier jeton du message suivant : des
// secondes sur un 27B, des dizaines sur un MoE aux experts en RAM.
//
// Avec la clé (off par défaut), Loki envoie au moteur local, dès qu'il est
// libre, la requête du prochain tour telle qu'elle partira — assemblée par
// les MÊMES fonctions (turnViewDry, wireMessages, buildChatPayload), contenu
// vivant, rien de figé ni de mémorisé pour gagner du cache — suivie d'un
// message utilisateur sentinelle « . », avec max_tokens 1. Le moteur calcule
// tout le préfixe et pose un point de reprise au début de ce dernier message :
// le vrai tour ne calcule plus que son propre message. Le jeton produit et la
// sentinelle sont jetés ; rien n'est persisté, ni dans la discussion, ni dans
// le journal, ni dans les compteurs (CtxUsed, stats, effort appris).
//
// Ce que le modèle voit ne change pas : llama-server ne reprend un état que sur
// un préfixe de jetons identique, et la vraie requête est construite à l'envoi,
// comme toujours. Au pire, le préchauffage ne sert à rien.
//
// Il ne passe jamais devant un vrai travail :
// - moteur local seulement, un seul slot (/props total_slots = 1 : ni
// PARALLEL ni -np d'EXTRA_ARGS ne peuvent le contredire) ;
// - jamais pendant un tour, une tâche, une compaction, un bench, un travail
// annexe, ni s'il reste une requête de Loki en vol (compteur de
// llm_slots.go, qu'il tient lui-même : l'isolation des slots n'efface rien
// pendant qu'il tourne) ;
// - toute requête de Loki vers le moteur l'annule à l'instant de partir —
// sauf un tour de chat dont la requête PROLONGE exactement le préfixe
// préchauffé (empreintes des messages sérialisés, des outils et des
// arguments du gabarit) : elle suit alors dans le même slot et réutilise
// ce qui vient d'être calculé, sans rien attendre d'inutile ;
// - un seul en vol ; un nouveau déclencheur annule l'ancien ;
// - aucune erreur ne remonte : 400 (contexte plein), 503 (chargement), 500
// (gabarit) sont lâchés, sans compaction, sans relance, sans rien apprendre.
//
// Déclencheurs : fin d'un tour (compaction de fin de tour comprise) quand rien
// n'attend dans la file, fin d'une tâche planifiée (slot déjà effacé, projet
// forcé déjà levé). PREWARM=full ajoute le changement de discussion, après 3 s
// sans nouveau changement. Les capacités sont celles du dernier tour de la
// discussion, gardées en mémoire ; en mode Code, le rôle est celui d'un message
// ordinaire (builder) — une demande de plan diverge et annule.
//
// Limites, sans effet sur la fidélité : un moteur sans point de reprise au
// début des messages utilisateur, ou des images juste avant (le moteur n'en
// pose pas après un bloc d'image), retombe sur un recalcul complet et exact ;
// changer agent, web ou mémoire avant d'envoyer rend le préchauffage inutile.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"io"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"time"
)
// prewarmMode : « on », « full » ou "" (off). Jamais pour un preset externe.
func prewarmMode(cfg map[string]string) string {
if isExternalConfig(cfg) {
return ""
}
switch strings.ToLower(strings.TrimSpace(cfg["PREWARM"])) {
case "on", "1", "true", "yes", "oui":
return "on"
case "full":
return "full"
}
return ""
}
// Déclencheurs (pour le journal).
const (
prewarmTurnEnd = "fin-tour"
prewarmAfterTask = "après-tâche"
prewarmSwitch = "discussion"
)
// prewarmSentinel : le message utilisateur qui tient la place du vrai.
const prewarmSentinel = "."
// prewarmRun : un préchauffage en vol. prefix couvre ses n premiers messages
// (sans la sentinelle), opts les champs qui façonnent le rendu.
type prewarmRun struct {
n int
prefix uint64
opts uint64
cancel context.CancelFunc
done chan struct{}
}
// prewarmCur : le préchauffage en vol, protégé par engineGate.mu (llm_slots.go).
// prewarmLive le reflète sans verrou : sans préchauffage, une requête ne
// calcule aucune empreinte.
var (
prewarmCur *prewarmRun
prewarmLive atomic.Bool
)
// prewarmDropLocked annule p, engineGate.mu tenu. Le client ferme la
// connexion, et llama-server arrête la tâche (au plus un micro-lot plus tard).
func prewarmDropLocked(p *prewarmRun) {
if prewarmCur == p {
prewarmCur = nil
prewarmLive.Store(false)
}
p.cancel()
}
// prewarmBegin inscrit p comme LA requête en vol, si le moteur est libre de
// toute autre requête de Loki. end le retire (une seule fois).
func prewarmBegin(p *prewarmRun) (end func(), ok bool) {
engineGate.mu.Lock()
defer engineGate.mu.Unlock()
if engineGate.inflight > 0 || prewarmCur != nil {
return nil, false
}
prewarmCur = p
prewarmLive.Store(true)
engineGate.inflight++
var once sync.Once
return func() {
once.Do(func() {
engineGate.mu.Lock()
engineGate.inflight--
if prewarmCur == p {
prewarmCur = nil
prewarmLive.Store(false)
}
engineGate.mu.Unlock()
})
}, true
}
// prewarmStop annule le préchauffage en vol et rend celui-ci (nil sinon), pour
// qu'on puisse attendre sa fin.
func prewarmStop() *prewarmRun {
// Cas de toujours : rien en vol, et pas de verrou à prendre (l'effacement
// d'un slot peut le tenir deux secondes).
if !prewarmLive.Load() {
return nil
}
engineGate.mu.Lock()
defer engineGate.mu.Unlock()
p := prewarmCur
if p != nil {
prewarmDropLocked(p)
}
return p
}
// matches : la requête dont prefix est l'empreinte cumulée des messages
// (perfPrefix) prolonge-t-elle exactement ce préchauffage ?
func (p *prewarmRun) matches(prefix []uint64, opts uint64) bool {
return p.n > 0 && len(prefix) > p.n && prefix[p.n-1] == p.prefix && opts == p.opts
}
// prewarmOptsKeys : les champs du corps, hors messages, qui changent le prompt
// rendu par le gabarit. L'échantillonnage n'y est pas : il ne touche pas au
// prompt.
var prewarmOptsKeys = []string{"tools", "parallel_tool_calls", "tool_choice", "chat_template_kwargs", "reasoning_effort"}
func prewarmOptsHash(payload map[string]any) uint64 {
h := fnv.New64a()
for _, k := range prewarmOptsKeys {
h.Write([]byte(k))
h.Write([]byte{0})
if v, ok := payload[k]; ok {
b, _ := json.Marshal(v)
h.Write(b)
}
h.Write([]byte{0})
}
return h.Sum64()
}
// prewarmKeeper : pour runChat, ce qui reconnaît un préchauffage à garder. nil
// (tout annuler) hors tour de chat, ou sans préchauffage en vol — dans ce cas,
// le cas de toujours, rien n'est calculé.
func prewarmKeeper(kind string, sent []Message, payload map[string]any) func(*prewarmRun) bool {
if kind != perfMain || !prewarmLive.Load() {
return nil
}
prefix, opts := perfPrefix(sent), prewarmOptsHash(payload)
return func(p *prewarmRun) bool { return p.matches(prefix, opts) }
}
// Capacités du dernier tour de chaque discussion, en mémoire seulement : le
// client les envoie avec chaque message, le serveur ne les connaît pas sinon.
var prewarmCaps struct {
mu sync.Mutex
m map[string]Caps
}
const prewarmCapsMax = 64
func prewarmNoteCaps(convID string, caps Caps) {
if convID == "" {
return
}
prewarmCaps.mu.Lock()
defer prewarmCaps.mu.Unlock()
if prewarmCaps.m == nil {
prewarmCaps.m = map[string]Caps{}
}
if _, ok := prewarmCaps.m[convID]; !ok && len(prewarmCaps.m) >= prewarmCapsMax {
for k := range prewarmCaps.m {
delete(prewarmCaps.m, k)
break
}
}
prewarmCaps.m[convID] = caps
}
func prewarmCapsFor(convID string) (Caps, bool) {
prewarmCaps.mu.Lock()
defer prewarmCaps.mu.Unlock()
c, ok := prewarmCaps.m[convID]
return c, ok
}
// prewarmNearMidnight : la date du jour fait partie du prompt système ; à
// moins de 10 min de minuit, le prochain message a toutes les chances de
// partir avec la suivante.
func prewarmNearMidnight(now time.Time) bool {
y, m, d := now.Date()
next := time.Date(y, m, d+1, 0, 0, 0, 0, now.Location())
return next.Sub(now) < 10*time.Minute
}
// Points d'accroche remplaçables par les tests.
var (
// prewarmAsync : false = le préchauffage tourne dans l'appelant (tests).
prewarmAsync = true
// prewarmSlots : nombre de slots du moteur local (/props total_slots).
prewarmSlots = func(ep chatEndpoint) int {
var props struct {
TotalSlots int `json:"total_slots"`
}
iso := slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient}
if err := iso.get(context.Background(), "/props", &props); err != nil {
return 0
}
return props.TotalSlots
}
prewarmSwitchDelay = 3 * time.Second
// prewarmTimeout : borne d'un préchauffage (un MoE aux experts en RAM met
// des minutes sur 20k jetons). Toute vraie requête l'annule bien avant.
prewarmTimeout = 15 * time.Minute
)
// prewarmKick : un déclencheur. Sans la clé, rien — ni goroutine, ni requête.
func (c *Conversation) prewarmKick(why string) {
if prewarmMode(ReadConfig()) == "" {
return
}
if prewarmAsync {
go c.prewarm(why)
return
}
c.prewarm(why)
}
// prewarmAfterSwitch : changement de discussion. Le préchauffage de l'ancienne
// est annulé ; avec PREWARM=full, la nouvelle est préparée si l'utilisateur y
// reste 3 s (cliquer d'une discussion à l'autre ne lance rien).
func prewarmAfterSwitch() {
prewarmStop() // sa fin n'est pas attendue : le changement ne doit pas bloquer
if prewarmMode(ReadConfig()) != "full" {
return
}
id := convActiveID()
time.AfterFunc(prewarmSwitchDelay, func() {
if convActiveID() == id {
conv.prewarmKick(prewarmSwitch)
}
})
}
// prewarmBodyFor construit le corps du préchauffage de c, ou "" avec la raison
// de s'abstenir. Lecture seule : rien n'est modifié dans la discussion.
func (c *Conversation) prewarmBodyFor(ep chatEndpoint, cfg map[string]string) (payload map[string]any, run *prewarmRun, skip string) {
caps, ok := prewarmCapsFor(convActiveID())
if !ok {
return nil, nil, "capacités du dernier tour inconnues"
}
caps = withTurnRole(caps, prewarmSentinel)
c.mu.Lock()
if c.Generating {
c.mu.Unlock()
return nil, nil, "moteur occupé"
}
msgs := append([]Message(nil), c.Messages...)
ctxNow := c.ctxNowLocked(msgs, true)
peak := c.genPeak
c.mu.Unlock()
hasUser := false
for _, m := range msgs {
if m.Role == "user" {
hasUser = true
break
}
}
// Sans message utilisateur dans l'historique, le contexte projet irait en
// tête de la sentinelle (normalizeSystemMessages) : rien à préparer.
if !hasUser {
return nil, nil, "discussion vide"
}
msgs = append(msgs, Message{Role: "user", Content: prewarmSentinel})
// Le prochain tour compactera d'abord : son prompt ne sera pas celui-ci.
if compactNeeded(msgs, ctxNow, peak) {
return nil, nil, "compaction attendue au prochain tour"
}
sent, tools := c.turnViewDry(caps, msgs, caps.Agent)
_, effort, _, kwargs := turnReasoning(cfg)
wire, _ := wireMessages(sent, false, turnEchoPolicy(false))
n := len(wire)
if n < 2 || wire[n-1].Role != "user" {
return nil, nil, "assemblage inattendu"
}
if s, _ := wire[n-1].Content.(string); s != prewarmSentinel {
return nil, nil, "assemblage inattendu"
}
payload = buildChatPayload(ep, wire, 0.7, chatPayloadOpts{tools: tools, effort: effort, kwargs: kwargs})
// Seuls les champs de réponse changent : une réponse d'un seul jeton, non
// streamée (jetée de toute façon).
payload["stream"] = false
delete(payload, "stream_options")
payload["max_tokens"] = 1
return payload, &prewarmRun{n: n - 1, prefix: perfPrefix(wire)[n-2], opts: prewarmOptsHash(payload)}, ""
}
var prewarmLogged sync.Map // raison d'échec → déjà journalisée
func prewarmLogOnce(key, format string, args ...any) {
if _, dup := prewarmLogged.LoadOrStore(key, true); dup {
return
}
fmt.Fprintf(os.Stderr, "[prewarm] "+format+"\n", args...)
}
// prewarm : un préchauffage complet, ou rien. Jamais d'erreur rendue.
func (c *Conversation) prewarm(why string) {
cfg := ReadConfig()
if prewarmMode(cfg) == "" {
return
}
ep := resolveChatEndpoint()
if ep.External {
return
}
// Un seul en vol : le nouveau déclencheur remplace l'ancien.
if p := prewarmStop(); p != nil {
select {
case <-p.done:
case <-time.After(5 * time.Second):
return
}
}
if sideJobs.Load() > 0 || benchRunning() || prewarmNearMidnight(time.Now()) {
return
}
if !healthCheck() {
return
}
if prewarmSlots(ep) != 1 {
return
}
payload, run, skip := c.prewarmBodyFor(ep, cfg)
if skip != "" {
return
}
body, err := json.Marshal(payload)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), prewarmTimeout)
defer cancel()
run.cancel, run.done = cancel, make(chan struct{})
defer close(run.done)
end, ok := prewarmBegin(run)
if !ok {
return // une requête de Loki est partie entre-temps
}
defer end()
ctx = withPerf(ctx, perfPrewarm, convActiveID())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ep.URL, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
ep.auth(req.Header.Set)
resp, err := http.DefaultClient.Do(req)
if err != nil {
if ctx.Err() == nil {
prewarmLogOnce("net", "moteur injoignable (%s) : %v", why, err)
}
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return
}
if resp.StatusCode != http.StatusOK {
prewarmLogOnce(resp.Status, "refusé par le moteur (%s) : %s %s", why, resp.Status, strings.TrimSpace(string(raw[:min(len(raw), 200)])))
return
}
// Télémétrie seulement (kind=prewarm) : la réponse est jetée.
rec := perfRecord(perfRecFromWire(perfTagOf(ctx), decodePerfWire(raw)), nil)
// Après une tâche, c'est lui qui retrouve (ou non) la conversation dans le
// cache RAM : la vérification de llm_slots.go se fait donc ici — le vrai
// tour, servi depuis le slot préchauffé, n'aurait plus rien à en dire. Le
// conseil va au journal (aucun fil où l'afficher).
if rec.Total > 0 {
cached := 0
if rec.Cached != nil {
cached = *rec.Cached
}
noteEnginePrompt(rec.Total, cached, rec.Cached != nil)
}
}
+525
View File
@@ -0,0 +1,525 @@
package loki
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// moteurPrewarm : faux llama-server qui note TOUTES les requêtes (chemin et
// corps), sert /health et /props, et répond aux complétions streamées comme aux
// autres.
type moteurPrewarm struct {
mu sync.Mutex
reqs []prewarmReq
slots int
}
type prewarmReq struct{ path, body string }
func (m *moteurPrewarm) 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)
m.mu.Lock()
m.reqs = append(m.reqs, prewarmReq{r.URL.Path, buf.String()})
slots := m.slots
m.mu.Unlock()
switch r.URL.Path {
case "/health":
sendJSON(w, 200, map[string]any{"status": "ok"})
case "/props":
sendJSON(w, 200, map[string]any{"total_slots": slots, "build_info": "b9000-x"})
default:
if strings.Contains(buf.String(), `"stream":true`) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sseChunk("ok.") + sseFinal("stop", 100, 2) + "data: [DONE]\n\n"))
return
}
sendJSON(w, 200, map[string]any{
"choices": []any{map[string]any{"message": map[string]any{"content": "x"}}},
"usage": map[string]any{"prompt_tokens": 120, "completion_tokens": 1},
"timings": map[string]any{"prompt_n": 4, "cache_n": 116, "predicted_n": 1},
})
}
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
}
func (m *moteurPrewarm) all() []prewarmReq {
m.mu.Lock()
defer m.mu.Unlock()
return append([]prewarmReq(nil), m.reqs...)
}
// completions : corps des complétions, streamées ou non.
func (m *moteurPrewarm) completions() []string {
var out []string
for _, r := range m.all() {
if r.path == "/v1/chat/completions" {
out = append(out, r.body)
}
}
return out
}
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
// préchauffage synchrone. keys : réglages posés.
func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm {
t.Helper()
withWorkspace(t)
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
setProjectOverride("")
t.Cleanup(func() {
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
setProjectOverride("")
})
ensureDefaultProject()
freshTplProbe(t)
convEnsureActive()
prewarmAsync = false
prewarmCaps.mu.Lock()
prewarmCaps.m = nil
prewarmCaps.mu.Unlock()
t.Cleanup(func() {
prewarmAsync = true
prewarmCaps.mu.Lock()
prewarmCaps.m = nil
prewarmCaps.mu.Unlock()
})
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
t.Fatal(err)
}
for k, v := range keys {
if err := SetConfigKey(k, v); err != nil {
t.Fatal(err)
}
}
m := &moteurPrewarm{slots: 1}
m.start(t)
return m
}
// prewarmTurn : un tour de chat comme StartTurn le lance (capacités notées pour
// le préchauffage), joué jusqu'au bout, préchauffage éventuel compris.
func prewarmTurn(t *testing.T, c *Conversation, caps Caps, text string) {
t.Helper()
prewarmNoteCaps(convActiveID(), caps)
c.mu.Lock()
c.Messages = append(c.Messages, um(text))
c.mu.Unlock()
c.generate(context.Background(), withTurnRole(caps, text), 0.7, c.epoch)
}
// Clé absente : pas une requête de plus au moteur — ni complétion, ni /health,
// ni /props — et la conversation est celle d'avant.
func TestPrewarmDefautAucuneRequete(t *testing.T) {
m := prewarmSetup(t, nil)
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
prewarmTurn(t, c, caps, "bonjour")
prewarmTurn(t, c, caps, "et ensuite ?")
reqs := m.all()
if len(reqs) != 2 {
t.Fatalf("2 requêtes attendues (une par tour), %d reçues : %+v", len(reqs), reqs)
}
for _, r := range reqs {
if r.path != "/v1/chat/completions" || strings.Contains(r.body, `"max_tokens"`) || !strings.Contains(r.body, `"stream":true`) {
t.Fatalf("requête inattendue sans la clé : %s %s", r.path, r.body)
}
}
// La fin de tour ne lance rien non plus en direct.
c.prewarm(prewarmTurnEnd)
if n := len(m.all()); n != 2 {
t.Fatalf("prewarm sans la clé a parlé au moteur (%d requêtes)", n)
}
}
// Preset externe : la clé est sans effet.
func TestPrewarmJamaisExterne(t *testing.T) {
cfg := map[string]string{"PREWARM": "on", extKeyFlag: "1", extKeyURL: "http://127.0.0.1:1"}
if prewarmMode(cfg) != "" {
t.Fatal("PREWARM actif sur un preset externe")
}
for v, want := range map[string]string{"": "", "off": "", "on": "on", "1": "on", "full": "full", "FULL": "full", "x": ""} {
if got := prewarmMode(map[string]string{"PREWARM": v}); got != want {
t.Fatalf("PREWARM=%q : %q, attendu %q", v, got, want)
}
}
}
// Avec la clé : la requête de préchauffage est celle du prochain tour, sentinelle
// en plus — mêmes messages jusqu'au dernier, mêmes outils et arguments du
// gabarit — avec max_tokens 1, sans flux. Rien n'en reste dans la discussion,
// et la télémétrie la range sous kind=prewarm.
func TestPrewarmEgalTourSuivantPlusSentinelle(t *testing.T) {
m := prewarmSetup(t, map[string]string{"PREWARM": "on", "REASONING_EFFORT": "high", "TOP_P": "0.9"})
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
t.Fatal(err)
}
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
prewarmTurn(t, c, caps, "bonjour")
comps := m.completions()
if len(comps) != 2 {
t.Fatalf("tour + préchauffage attendus, %d complétions", len(comps))
}
var pw map[string]any
if err := json.Unmarshal([]byte(comps[1]), &pw); err != nil {
t.Fatal(err)
}
if pw["stream"] != false || pw["max_tokens"] != float64(1) || pw["stream_options"] != nil {
t.Fatalf("champs de réponse du préchauffage : stream=%v max_tokens=%v stream_options=%v", pw["stream"], pw["max_tokens"], pw["stream_options"])
}
c.mu.Lock()
for _, msg := range c.Messages {
if s, _ := msg.Content.(string); s == prewarmSentinel {
c.mu.Unlock()
t.Fatal("la sentinelle a été rangée dans la discussion")
}
}
c.mu.Unlock()
prewarmTurn(t, c, caps, "et ensuite ?")
comps = m.completions()
var real map[string]any
if err := json.Unmarshal([]byte(comps[2]), &real); err != nil {
t.Fatal(err)
}
pm, rm := pw["messages"].([]any), real["messages"].([]any)
if len(pm) != len(rm) {
t.Fatalf("préchauffage %d messages, tour suivant %d", len(pm), len(rm))
}
if !reflect.DeepEqual(pm[:len(pm)-1], rm[:len(rm)-1]) {
t.Fatalf("préfixe différent du tour suivant :\npréchauffage %v\ntour %v", pm[:len(pm)-1], rm[:len(rm)-1])
}
if last := pm[len(pm)-1].(map[string]any); last["role"] != "user" || last["content"] != prewarmSentinel {
t.Fatalf("dernier message du préchauffage : %v", last)
}
for _, k := range append([]string{"temperature", "top_p", "model"}, prewarmOptsKeys...) {
if !reflect.DeepEqual(pw[k], real[k]) {
t.Fatalf("champ %s : préchauffage %v, tour %v", k, pw[k], real[k])
}
}
found := false
for _, r := range perfLog.snapshot() {
if r.Kind == perfPrewarm {
found = true
}
}
if !found {
t.Fatal("aucune complétion kind=prewarm dans la télémétrie")
}
}
// Les garde-fous : plus d'un slot, tour en cours, capacités inconnues, travail
// annexe — aucune complétion de préchauffage.
func TestPrewarmGardeFous(t *testing.T) {
m := prewarmSetup(t, map[string]string{"PREWARM": "on"})
c := newTestConv()
caps := Caps{Agent: true}
c.mu.Lock()
c.Messages = []Message{um("bonjour"), am("salut")}
c.mu.Unlock()
count := func() int { return len(m.completions()) }
// Capacités inconnues (aucun tour joué dans cette discussion).
c.prewarm(prewarmTurnEnd)
if count() != 0 {
t.Fatal("préchauffage sans capacités connues")
}
prewarmNoteCaps(convActiveID(), caps)
m.mu.Lock()
m.slots = 2
m.mu.Unlock()
c.prewarm(prewarmTurnEnd)
if count() != 0 {
t.Fatal("préchauffage avec deux slots")
}
m.mu.Lock()
m.slots = 1
m.mu.Unlock()
c.mu.Lock()
c.Generating = true
c.mu.Unlock()
c.prewarm(prewarmTurnEnd)
c.mu.Lock()
c.Generating = false
c.mu.Unlock()
if count() != 0 {
t.Fatal("préchauffage pendant un tour")
}
sideJobs.Add(1)
c.prewarm(prewarmTurnEnd)
sideJobs.Add(-1)
if count() != 0 {
t.Fatal("préchauffage pendant un travail annexe")
}
end := engineRequestStart()
c.prewarm(prewarmTurnEnd)
end()
if count() != 0 {
t.Fatal("préchauffage avec une requête de Loki en vol")
}
c.prewarm(prewarmTurnEnd)
if count() != 1 {
t.Fatalf("toutes conditions réunies : 1 préchauffage attendu, %d", count())
}
}
// Toute requête de Loki annule le préchauffage en vol, sauf un tour de chat qui
// prolonge exactement son préfixe.
func TestPrewarmAnnuleSaufPrefixeExact(t *testing.T) {
hist := []Message{{Role: "system", Content: "sys"}, um("a"), am("b")}
pwMsgs := append(append([]Message(nil), hist...), um(prewarmSentinel))
real := append(append([]Message(nil), hist...), um("suite"))
payload := map[string]any{"tools": []string{"t"}, "parallel_tool_calls": false}
other := []Message{{Role: "system", Content: "sys"}, um("a"), am("c"), um("suite")}
// Sans préchauffage en vol, rien n'est calculé.
if prewarmKeeper(perfMain, real, payload) != nil {
t.Fatal("empreintes calculées sans préchauffage en vol")
}
cases := []struct {
name string
keep func() func(*prewarmRun) bool // construit préchauffage en vol
kept bool
}{
{"tour qui prolonge", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, real, payload) }, true},
{"autres outils", func() func(*prewarmRun) bool {
return prewarmKeeper(perfMain, real, map[string]any{"tools": []string{"u"}, "parallel_tool_calls": false})
}, false},
{"autre historique", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, other, payload) }, false},
{"préfixe seul, rien après", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, hist, payload) }, false},
{"compaction", func() func(*prewarmRun) bool { return prewarmKeeper(perfCompact, real, payload) }, false},
{"requête quelconque", func() func(*prewarmRun) bool { return nil }, false},
}
for _, tc := range cases {
ctx, cancel := context.WithCancel(context.Background())
run := &prewarmRun{n: len(pwMsgs) - 1, prefix: perfPrefix(pwMsgs)[len(pwMsgs)-2], opts: prewarmOptsHash(payload), cancel: cancel, done: make(chan struct{})}
end, ok := prewarmBegin(run)
if !ok {
t.Fatal("prewarmBegin refusé, moteur libre")
}
done := engineRequestStartKeep(tc.keep())
if kept := ctx.Err() == nil; kept != tc.kept {
t.Fatalf("%s : gardé=%v, attendu %v", tc.name, kept, tc.kept)
}
done()
end()
cancel()
}
// Une requête en vol interdit d'en démarrer un.
busy := engineRequestStart()
if _, ok := prewarmBegin(&prewarmRun{cancel: func() {}}); ok {
t.Fatal("préchauffage inscrit avec une requête en vol")
}
busy()
if prewarmLive.Load() {
t.Fatal("prewarmLive resté levé")
}
}
// turnViewDry ne touche à rien (instantané, mises à jour, nettoyage) et rend,
// jusqu'au dernier message, ce que turnView enverra.
func TestTurnViewDryNeTouchePas(t *testing.T) {
for _, on := range []bool{false, true} {
m := projSnapSetup(t, on)
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
playTurn(t, m, c, caps, "bonjour")
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
t.Fatal(err)
}
if !on {
// Reste d'une époque avec la clé : turnView le nettoierait.
c.mu.Lock()
c.ProjSnap = &projSnapshot{Key: "x"}
c.mu.Unlock()
}
c.mu.Lock()
before, _ := json.Marshal(c)
hist := append([]Message(nil), c.Messages...)
c.mu.Unlock()
dry, _ := c.turnViewDry(caps, append(append([]Message(nil), hist...), um(prewarmSentinel)), true)
c.mu.Lock()
after, _ := json.Marshal(c)
c.mu.Unlock()
if !bytes.Equal(before, after) {
t.Fatalf("on=%v : turnViewDry a modifié la discussion", on)
}
sent, _, _ := c.turnView(caps, c.epoch, append(append([]Message(nil), hist...), um("suite")), true)
dw, sw := normalizeSystemMessages(dry), normalizeSystemMessages(sent)
if len(dw) != len(sw) || !reflect.DeepEqual(jsonValue(t, dw[:len(dw)-1]), jsonValue(t, sw[:len(sw)-1])) {
t.Fatalf("on=%v : préfixe de turnViewDry ≠ turnView\n%v\n%v", on, jsonValue(t, dw), jsonValue(t, sw))
}
}
}
// oldChatPayload : la construction du corps dans runChat avant buildChatPayload,
// recopiée telle quelle.
func oldChatPayload(ep chatEndpoint, messages []Message, temperature float64, tools []Tool, disableTools, toolChoiceNone, echoOff bool, effort string, kwargs map[string]any) map[string]any {
sent := normalizeSystemMessages(messages)
if toolChoiceNone {
sent = withTrailingHint(sent, toolsOffHint)
}
pol := currentEchoPolicy()
if ep.External || echoOff {
pol = echoPolicy{}
}
sent, _ = echoMessages(sent, pol)
payload := map[string]any{
"model": ep.Model,
"messages": expandImageRefs(sent),
"stream": true,
"temperature": temperature,
"stream_options": map[string]any{"include_usage": true},
}
applySampling(payload)
if effort != "" {
payload["reasoning_effort"] = effort
}
if kwargs != nil && !ep.External {
payload["chat_template_kwargs"] = kwargs
}
if len(tools) > 0 && !disableTools {
payload["tools"] = tools
payload["parallel_tool_calls"] = false
if toolChoiceNone {
payload["tool_choice"] = "none"
}
}
return payload
}
// oldTurnReasoning : le calcul du raisonnement dans runChat d'avant, recopié.
func oldTurnReasoning(cfg map[string]string) (string, string, bool, map[string]any) {
effortWanted := reasoningEffortValue(cfg["REASONING_EFFORT"])
reasoningEffort := effortResolve(effortWanted)
thinkOff := reasoningExplicitlyOff(cfg["REASONING"]) || effortWanted == "none"
return effortWanted, reasoningEffort, thinkOff, reasoningTemplateKwargs(thinkOff, reasoningEffort)
}
// Refactorisation : le corps construit par wireMessages + buildChatPayload est,
// octet pour octet, celui d'avant — sur toute la matrice des options d'une
// requête (outils coupés ou neutralisés, intensité, gabarit, API externe,
// raisonnement renvoyé, images).
func TestBuildChatPayloadIdentiqueAvant(t *testing.T) {
m := prewarmSetup(t, map[string]string{"TOP_P": "0.9", "TOP_K": "20", "REASONING_ECHO": "on"})
_ = m
tools := EnabledTools(Caps{Agent: true, Mem: MemAlways})
if len(tools) == 0 {
t.Fatal("aucun outil pour la matrice")
}
msgs := []Message{
{Role: "system", Content: "sys"},
projectContextMessageForTest(),
um("bonjour"),
{Role: "assistant", Content: "salut", ReasoningContent: "je pense", ReasoningModel: echoTestModel},
{Role: "user", Content: []any{map[string]any{"type": "text", "text": "et ça ?"}, map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}}},
}
eps := []chatEndpoint{{URL: "http://localhost:1/v1/chat/completions", Model: "loki"}, {URL: "https://api.example/v1/chat/completions", Model: "gpt", External: true}}
for _, effortCfg := range []string{"", "high", "none"} {
cfg := map[string]string{"REASONING_EFFORT": effortCfg, "REASONING": "on"}
w1, e1, o1, k1 := oldTurnReasoning(cfg)
w2, e2, o2, k2 := turnReasoning(cfg)
if w1 != w2 || e1 != e2 || o1 != o2 || !reflect.DeepEqual(k1, k2) {
t.Fatalf("turnReasoning(%q) différent d'avant", effortCfg)
}
for _, ep := range eps {
for _, tl := range [][]Tool{nil, tools} {
for _, disable := range []bool{false, true} {
for _, none := range []bool{false, true} {
for _, echoOff := range []bool{false, true} {
want := oldChatPayload(ep, msgs, 0.7, tl, disable, none, echoOff, e2, k2)
sent, _ := wireMessages(msgs, none, turnEchoPolicy(ep.External || echoOff))
got := buildChatPayload(ep, sent, 0.7, chatPayloadOpts{tools: tl, disableTools: disable, toolChoiceNone: none, effort: e2, kwargs: k2})
wb, _ := json.Marshal(want)
gb, _ := json.Marshal(got)
if !bytes.Equal(wb, gb) {
t.Fatalf("corps différent (effort=%q ext=%v outils=%d off=%v none=%v echoOff=%v)\navant %s\naprès %s",
effortCfg, ep.External, len(tl), disable, none, echoOff, wb, gb)
}
}
}
}
}
}
}
}
// projectContextMessageForTest : un message projet, que normalizeSystemMessages
// déplace en tête du premier message utilisateur.
func projectContextMessageForTest() Message {
return renderProjectContext("Projet de test")
}
// Refactorisation, de bout en bout : la requête d'un tour joué par generate est
// celle de l'assemblage et du corps d'avant, pour chaque combinaison de modes.
func TestGenerateCorpsIdentiqueAvant(t *testing.T) {
m := prewarmSetup(t, map[string]string{"REASONING_EFFORT": "high", "TOP_P": "0.9"})
matrix := []Caps{
{},
{Agent: true},
{Agent: true, Mem: MemAlways},
{Agent: true, Mem: MemOnDemand, Internet: true},
{Agent: true, Code: true, Role: "builder"},
{Agent: true, Code: true, Role: "planner"},
}
for i, caps := range matrix {
c := newTestConv()
c.mu.Lock()
c.Messages = []Message{um("bonjour"), am("salut")}
history := append(append([]Message(nil), c.Messages...), um("suite"))
c.mu.Unlock()
_, effort, _, kwargs := oldTurnReasoning(ReadConfig())
final := oldAssembly(caps, history) // normalisé
want := oldChatPayload(resolveChatEndpoint(), final, 0.7, EnabledTools(caps), false, false, false, effort, kwargs)
before := len(m.completions())
prewarmTurn(t, c, caps, "suite")
comps := m.completions()
if len(comps) <= before {
t.Fatalf("cas %d : aucune requête", i)
}
var got any
if err := json.Unmarshal([]byte(comps[before]), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, jsonValue(t, want)) {
t.Fatalf("cas %d (%+v) : corps différent d'avant\nreçu %v\nattendu %v", i, caps, got, jsonValue(t, want))
}
}
}
func TestPrewarmNearMidnight(t *testing.T) {
loc := time.Local
if !prewarmNearMidnight(time.Date(2026, 10, 4, 23, 55, 0, 0, loc)) {
t.Fatal("23:55 doit s'abstenir")
}
if prewarmNearMidnight(time.Date(2026, 10, 4, 23, 45, 0, 0, loc)) || prewarmNearMidnight(time.Date(2026, 10, 4, 0, 5, 0, 0, loc)) {
t.Fatal("23:45 et 00:05 doivent préchauffer")
}
}
+43 -11
View File
@@ -233,7 +233,7 @@ func projSnapKey(caps Caps, cfg map[string]string) string {
// projSnapIgnoredKeys : réglages changés en cours de discussion sans relancer le
// moteur ni toucher au bloc. Les compter rafraîchirait pour rien.
var projSnapIgnoredKeys = map[string]bool{
"PROJ_SNAPSHOT": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true,
"PROJ_SNAPSHOT": true, "PREWARM": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true,
"COMPACT": true, "CRAWL4AI_URL": true, "CRAWL4AI_KEY": true, "WEB_ENGINE": true,
}
@@ -515,6 +515,20 @@ func replaceProjectHead(msgs []Message, block []Message) []Message {
// (sous c.mu, si l'epoch n'a pas changé). pt sert au rafraîchissement en cours
// de tour (runChatTools) ; nil sans la clé.
func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject bool) (sent []Message, tools []Tool, pt *projSnapTurn) {
return c.assembleTurn(caps, epoch, msgs, inject, false)
}
// turnViewDry : la séquence que turnView enverrait pour msgs, SANS rien toucher
// à la conversation — ni instantané pris, ni mise à jour rangée, ni nettoyage.
// Pour le préchauffage (chat_prewarm.go) : contenu vivant, mêmes décisions que
// le vrai tour. La mise à jour qu'il poserait en tête du dernier message n'y est
// pas : ce message-là est la sentinelle, hors du préfixe préchauffé.
func (c *Conversation) turnViewDry(caps Caps, msgs []Message, inject bool) ([]Message, []Tool) {
sent, tools, _ := c.assembleTurn(caps, 0, msgs, inject, true)
return sent, tools
}
func (c *Conversation) assembleTurn(caps Caps, epoch int, msgs []Message, inject, dry bool) (sent []Message, tools []Tool, pt *projSnapTurn) {
cfg := ReadConfig()
on := projSnapEnabled(cfg)
if !inject {
@@ -526,7 +540,7 @@ func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject boo
}
if !on {
c.mu.Lock()
if c.epoch == epoch && (c.ProjSnap != nil || hasContextUpdates(c.Messages)) {
if !dry && c.epoch == epoch && (c.ProjSnap != nil || hasContextUpdates(c.Messages)) {
c.ProjSnap = nil
c.Messages = stripContextUpdates(c.Messages)
}
@@ -559,6 +573,17 @@ func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject boo
block := live.msgs
c.mu.Lock()
if dry {
// Même décision que projSnapApplyLocked, appliquée à la seule copie.
if refresh, _, _ := projSnapDecide(c.ProjSnap, live, key, sysHash, asOf); refresh {
msgs = stripContextUpdates(msgs)
} else {
block = c.ProjSnap.Msgs
}
c.mu.Unlock()
final := append(append(append([]Message(nil), spMsgs...), block...), msgs...)
return InjectSkills(final, caps, tools), tools, nil
}
if c.epoch == epoch {
refresh, delta := c.projSnapApplyLocked(live, key, sysHash, asOf)
if refresh {
@@ -586,15 +611,7 @@ func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject boo
// que l'appelant l'applique à sa propre copie.
func (c *Conversation) projSnapApplyLocked(live projLive, key, sysHash, asOf string) (refresh bool, delta string) {
snap := c.ProjSnap
refresh = snap == nil || snap.Key != key || snap.SysHash != sysHash
tok := 0
if !refresh {
delta = projDelta(snap.State, live, asOf)
tok = len(delta) / 4
if delta != "" && snap.DeltaTok+tok > projSnapDeltaLimit(snap.SnapTok) {
refresh = true
}
}
refresh, delta, tok := projSnapDecide(snap, live, key, sysHash, asOf)
if refresh {
c.Messages = stripContextUpdates(c.Messages)
c.ProjSnap = newProjSnapshot(live, key, sysHash)
@@ -619,6 +636,21 @@ func (c *Conversation) projSnapApplyLocked(live projLive, key, sysHash, asOf str
return false, delta
}
// projSnapDecide : rafraîchir ou non, et la mise à jour à annoncer sinon (tok :
// sa taille estimée). Pur : projSnapApplyLocked l'applique, le préchauffage
// (turnViewDry) s'en sert pour prévoir le tour sans rien toucher.
func projSnapDecide(snap *projSnapshot, live projLive, key, sysHash, asOf string) (refresh bool, delta string, tok int) {
refresh = snap == nil || snap.Key != key || snap.SysHash != sysHash
if !refresh {
delta = projDelta(snap.State, live, asOf)
tok = len(delta) / 4
if delta != "" && snap.DeltaTok+tok > projSnapDeltaLimit(snap.SnapTok) {
refresh = true
}
}
return refresh, delta, tok
}
// --- rafraîchissement en cours de tour -------------------------------------
// projSnapTurn accompagne un tour de discussion avec la clé posée. runChatTools
+2
View File
@@ -220,6 +220,7 @@ func projectSwitch(slug string) error {
}
// convIndex rend la plus récemment modifiée en tête.
convActivate(list[0].ID, storeBytes(bkChat, convKey(list[0].ID)))
prewarmAfterSwitch()
return nil
}
@@ -247,6 +248,7 @@ func convSwitch(id string) error {
}
conv.persist() // fige la discussion qu'on quitte
convActivate(id, storeBytes(bkChat, convKey(id)))
prewarmAfterSwitch()
return nil
}
+14
View File
@@ -42,3 +42,17 @@ func wantsPlan(text string) bool {
}
return false
}
// withTurnRole fige le rôle d'un tour du mode Code d'après son message : plan
// demandé → planner, sinon builder. Un rôle déjà choisi par le client est gardé.
// Partagé par StartTurn et le préchauffage (chat_prewarm.go), qui prévoit le
// rôle le plus probable du prochain message.
func withTurnRole(caps Caps, text string) Caps {
if caps.Code && caps.Role == "" {
caps.Role = "builder"
if wantsPlan(text) {
caps.Role = "planner"
}
}
return caps
}
+109 -64
View File
@@ -1117,6 +1117,101 @@ func toolCallLabel(name string, args map[string]any) string {
return label
}
// turnReasoning : ce que la requête dit du raisonnement, tiré de la
// configuration. Partagé par runChat et le préchauffage (chat_prewarm.go).
func turnReasoning(cfg map[string]string) (effortWanted, effort string, thinkOff bool, kwargs map[string]any) {
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
effortWanted = reasoningEffortValue(cfg["REASONING_EFFORT"])
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
// on part alors directement sur la traduction apprise, sans repayer le 500.
effort = effortResolve(effortWanted)
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
// `enable_thinking` qui porte alors la consigne, seul.
thinkOff = reasoningExplicitlyOff(cfg["REASONING"]) || effortWanted == "none"
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
// modèle réfléchissait pendant que l'interface annonçait le contraire.
kwargs = reasoningTemplateKwargs(thinkOff, effort)
return effortWanted, effort, thinkOff, kwargs
}
// turnEchoPolicy : la politique de REASONING_ECHO d'une requête ; off = preset
// externe ou repli en cours, rien ne repart.
func turnEchoPolicy(off bool) echoPolicy {
if off {
return echoPolicy{}
}
return currentEchoPolicy()
}
// wireMessages : les messages tels qu'ils partent au moteur — un seul système
// en tête, la consigne de fin si les outils sont neutralisés, le raisonnement
// renvoyé selon pol — et le nombre de raisonnements renvoyés.
func wireMessages(messages []Message, toolChoiceNone bool, pol echoPolicy) ([]Message, int) {
sent := normalizeSystemMessages(messages)
if toolChoiceNone {
sent = withTrailingHint(sent, toolsOffHint)
}
return echoMessages(sent, pol)
}
// chatPayloadOpts : ce qui, en plus des messages, façonne une requête de chat.
type chatPayloadOpts struct {
tools []Tool
disableTools bool
toolChoiceNone bool
effort string
kwargs map[string]any
}
// buildChatPayload : le corps d'une requête de complétion de chat, à partir des
// messages déjà passés par wireMessages. UN seul endroit le construit : le
// préchauffage (chat_prewarm.go) envoie ce même corps, aux seuls champs de
// réponse près (stream, max_tokens), et ne peut donc pas en dériver.
func buildChatPayload(ep chatEndpoint, sent []Message, temperature float64, o chatPayloadOpts) map[string]any {
payload := map[string]any{
"model": ep.Model,
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(sent),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
// du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
// ne compte que les tokens nouvellement traités. Sert au comptage exact du
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
"stream_options": map[string]any{"include_usage": true},
}
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
// 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.effort != "" {
payload["reasoning_effort"] = o.effort
}
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
// distante stricte (OpenAI) refuse un argument inconnu (400).
if o.kwargs != nil && !ep.External {
payload["chat_template_kwargs"] = o.kwargs
}
if len(o.tools) > 0 && !o.disableTools {
payload["tools"] = o.tools
// The model sometimes emits parallel tool calls, which this llama.cpp
// build serialises as two concatenated JSON objects in one arguments
// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
// single tool call per turn avoids that.
payload["parallel_tool_calls"] = false
if o.toolChoiceNone {
payload["tool_choice"] = "none"
}
}
return payload
}
// injectQueued (optionnel, variadique pour ne pas toucher aux appels hors chat)
// est consulté à CHAQUE frontière d'étape de la boucle d'outils : il renvoie les
// messages utilisateur mis en file PENDANT la génération, pour que le modèle les
@@ -1157,21 +1252,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// (perf_log.go) : rien de tout ça ne part dans la requête.
ptag, perfIter := perfTagOf(ctx), 0
reasoningOn := reasoningActive(chatCfg["REASONING"])
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
effortWanted := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
// on part alors directement sur la traduction apprise, sans repayer le 500.
reasoningEffort := effortResolve(effortWanted)
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
// `enable_thinking` qui porte alors la consigne, seul.
thinkOff := reasoningExplicitlyOff(chatCfg["REASONING"]) || effortWanted == "none"
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
// modèle réfléchissait pendant que l'interface annonçait le contraire.
reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort)
effortWanted, reasoningEffort, thinkOff, reasoningKwargs := turnReasoning(chatCfg)
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
// 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.
@@ -1289,55 +1370,17 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
// Gardé à part : la télémétrie compare ces messages-là d'une requête à
// l'autre (perfPrefix).
sent := normalizeSystemMessages(messages)
if toolChoiceNone {
sent = withTrailingHint(sent, toolsOffHint)
}
// Raisonnement renvoyé : décidé ici, au point de sortie unique, pour
// chaque requête. Clé absente (ou preset externe, ou repli en cours) :
// Raisonnement renvoyé : décidé au point de sortie unique, pour chaque
// requête. Clé absente (ou preset externe, ou repli en cours) :
// echoMessages rend la tranche telle quelle, requête inchangée.
pol := currentEchoPolicy()
if ep.External || echoOffTurn {
pol = echoPolicy{}
}
sent, echoSent := echoMessages(sent, pol)
payload := map[string]any{
"model": ep.Model,
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(sent),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
// du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
// ne compte que les tokens nouvellement traités. Sert au comptage exact du
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
"stream_options": map[string]any{"include_usage": true},
}
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
// 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 reasoningEffort != "" {
payload["reasoning_effort"] = reasoningEffort
}
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
// distante stricte (OpenAI) refuse un argument inconnu (400).
if reasoningKwargs != nil && !ep.External {
payload["chat_template_kwargs"] = reasoningKwargs
}
if len(tools) > 0 && !disableTools {
payload["tools"] = tools
// The model sometimes emits parallel tool calls, which this llama.cpp
// build serialises as two concatenated JSON objects in one arguments
// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
// single tool call per turn avoids that.
payload["parallel_tool_calls"] = false
if toolChoiceNone {
payload["tool_choice"] = "none"
}
}
// Assemblage partagé avec le préchauffage (chat_prewarm.go) : les deux ne
// peuvent pas diverger.
pol := turnEchoPolicy(ep.External || echoOffTurn)
sent, echoSent := wireMessages(messages, toolChoiceNone, pol)
payload := buildChatPayload(ep, sent, temperature, chatPayloadOpts{
tools: tools, disableTools: disableTools, toolChoiceNone: toolChoiceNone,
effort: reasoningEffort, kwargs: reasoningKwargs,
})
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil {
@@ -1352,9 +1395,11 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// Requête en vol vers le moteur local, jusqu'à la lecture complète du corps :
// l'isolation des travaux annexes n'efface jamais le slot pendant ce temps
// (llm_slots.go). Rien à compter pour une API externe.
// 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() {}
if !ep.External {
endReq = engineRequestStart()
endReq = engineRequestStartKeep(prewarmKeeper(ptag.kind, sent, payload))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
+13 -2
View File
@@ -82,9 +82,20 @@ var sideJobs atomic.Int32
// engineRequestStart marque une requête vers le moteur local ; la fonction
// rendue la clôt (une seule fois, quel que soit le nombre d'appels), une fois
// le corps de la réponse lu.
func engineRequestStart() func() {
// le corps de la réponse lu. Un préchauffage en vol (PREWARM, chat_prewarm.go)
// est annulé d'abord : il ne passe jamais devant un vrai travail.
func engineRequestStart() func() { return engineRequestStartKeep(nil) }
// engineRequestStartKeep : engineRequestStart, sauf pour le préchauffage en vol
// que keep reconnaît comme le début exact de cette requête — il continue, et la
// requête le suit dans le même slot sur le préfixe qu'il vient de calculer.
// 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() {
engineGate.mu.Lock()
if p := prewarmCur; p != nil && (keep == nil || !keep(p)) {
prewarmDropLocked(p)
}
engineGate.inflight++
engineGate.mu.Unlock()
var once sync.Once
+1
View File
@@ -37,6 +37,7 @@ const (
perfCompact = "compact" // résumé de compaction
perfBench = "bench" // mesure du moteur (cache_prompt:false)
perfForeign = "foreign" // client externe passé par /v1 : vu, pas mesuré
perfPrewarm = "prewarm" // préchauffage du cache (PREWARM) : réponse jetée
)
type perfCtxKey struct{}
+4
View File
@@ -106,6 +106,10 @@ var configTemplate = []struct{ key, help string }{
"ses changements en <context_update> en tête du message suivant : une page créée ou une valeur de tracker ne fait plus " +
"recalculer toute la conversation. Repris tout neuf à chaque compaction, redémarrage ou changement de modèle/projet. " +
"Vide/off (défaut) = bloc reconstruit à chaque tour ; sans effet sur un preset externe"},
{"PREWARM", "on = préparer le prochain tour pendant que tu lis : après un tour (compaction comprise) ou une tâche, " +
"Loki envoie au moteur local la requête suivante avec un message « . » et 1 jeton de réponse, jetés ; le vrai message " +
"ne calcule plus que lui-même. Annulé dès qu'une autre requête part, sauf si elle prolonge exactement ce préfixe ; " +
"jamais avec plus d'un slot. full = aussi au changement de discussion (après 3 s). Vide/off (défaut) = aucune requête de plus"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
+5
View File
@@ -175,6 +175,11 @@ func runTask(t Task) {
// dans la mémoire du projet ouvert à l'écran au moment du tic. Sûr : une seule
// inférence tourne à la fois (verrou de génération), et RunAutonomous a déjà
// rendu la main quand on libère.
// Préchauffage de la discussion ouverte (PREWARM, off par défaut) une fois
// la tâche finie : différé AVANT la levée du projet forcé, donc exécuté
// APRÈS elle — le contexte préparé est celui de la discussion, pas de la
// tâche. Le slot a déjà été effacé à la fin de RunAutonomous.
defer conv.prewarmKick(prewarmAfterTask)
setProjectOverride(taskProjectOf(t))
defer setProjectOverride("")
+1 -1
View File
@@ -7520,7 +7520,7 @@ function renderStats(el, s){
}
// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go),
// dit en clair : les identifiants internes n'ont rien à faire dans l'interface.
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'};
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1', prewarm:'préchauffage'};
// Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ».
function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); }
// --- Compteurs de bulle -----------------------------------------------------
+1 -1
View File
@@ -263,7 +263,7 @@ function renderStats(el, s){
}
// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go),
// dit en clair : les identifiants internes n'ont rien à faire dans l'interface.
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'};
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1', prewarm:'préchauffage'};
// Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ».
function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); }
// --- Compteurs de bulle -----------------------------------------------------