diff --git a/README.md b/README.md index ba9b020..cabf607 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,24 @@ Ajoutées par ce fork : tâches planifiées gardent le bloc à jour à chaque tour ; sans agent, rien du projet n'est envoyé ; un preset externe (API) n'est jamais concerné. Le titre, l'export JSON, le résumé de compaction et la passe de vérification ne voient pas ces blocs. +- **Préchauffage du prochain tour** (clé `PREWARM`, **off** par défaut, + `loki config set PREWARM on`) : certains recalculs sont inévitables — prompt + réécrit par une compaction, dernier message rendu autrement au tour suivant, + discussion reprise après une tâche planifiée qui a pris le slot — et sans la + clé ils retardent le premier mot du message suivant. Avec `on`, dès que le + moteur local est libre après un tour ou une tâche, Loki lui envoie la requête + du prochain tour, assemblée par les mêmes fonctions que la vraie (contenu + vivant, rien de figé), suivie d'un message `.` et limitée à 1 jeton : le + moteur calcule le préfixe pendant que tu lis, et le vrai message ne calcule + plus que lui-même. Le jeton et le `.` sont jetés, rien n'est enregistré. Toute + autre requête de Loki l'annule aussitôt, sauf un message dont la requête + prolonge exactement le préfixe préparé (même historique, mêmes outils, mêmes + réglages du gabarit). Jamais avec plus d'un slot (`PARALLEL` ou `-np` dans + `EXTRA_ARGS`), pendant un tour, une tâche, un bench, ni vers un preset externe. + `full` prépare aussi la discussion qu'on ouvre, si on y reste 3 s. Visible dans + 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). - **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_conversation.go b/internal/loki/chat_conversation.go index c98972f..485f204 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -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. diff --git a/internal/loki/chat_prewarm.go b/internal/loki/chat_prewarm.go new file mode 100644 index 0000000..27f3861 --- /dev/null +++ b/internal/loki/chat_prewarm.go @@ -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) + } +} diff --git a/internal/loki/chat_prewarm_test.go b/internal/loki/chat_prewarm_test.go new file mode 100644 index 0000000..9af4ae0 --- /dev/null +++ b/internal/loki/chat_prewarm_test.go @@ -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") + } +} diff --git a/internal/loki/chat_projsnap.go b/internal/loki/chat_projsnap.go index 36e5041..4bb0ecb 100644 --- a/internal/loki/chat_projsnap.go +++ b/internal/loki/chat_projsnap.go @@ -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 diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index a1ab0e7..5112574 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -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 } diff --git a/internal/loki/code_roles.go b/internal/loki/code_roles.go index 342c27f..6bee3ef 100644 --- a/internal/loki/code_roles.go +++ b/internal/loki/code_roles.go @@ -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 +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index f5ab273..a80eca4 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -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 { diff --git a/internal/loki/llm_slots.go b/internal/loki/llm_slots.go index 7cd43f4..e78f9da 100644 --- a/internal/loki/llm_slots.go +++ b/internal/loki/llm_slots.go @@ -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 diff --git a/internal/loki/perf_log.go b/internal/loki/perf_log.go index 420632d..8f8ccd1 100644 --- a/internal/loki/perf_log.go +++ b/internal/loki/perf_log.go @@ -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{} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index d10545e..080045f 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -106,6 +106,10 @@ var configTemplate = []struct{ key, help string }{ "ses changements en 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"}, diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go index 31d6006..050a280 100644 --- a/internal/loki/tasks_run.go +++ b/internal/loki/tasks_run.go @@ -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("") diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 946de83..0cc8273 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -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 ----------------------------------------------------- diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index d4bb0cd..a8cffeb 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -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 -----------------------------------------------------