From 786c3199c3a18b3bbc273c93dadb46d4d00cf55e Mon Sep 17 00:00:00 2001 From: youtsuho <110821381+youtsuhodev@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:36:17 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Prompt=20:=20le=20pr=C3=A9fixe=20syst=C3=A8?= =?UTF-8?q?me=20+=20outils=20est=20chauff=C3=A9=20d=C3=A8s=20que=20le=20mo?= =?UTF-8?q?teur=20r=C3=A9pond,=20en=20t=C3=A2che=20de=20fond?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le préfill du premier message d'une conversation était payé à chaque démarrage du moteur. Un pré-chauffage interne (llm_prewarm.go) envoie la même requête max_tokens=1 sur le préfixe exact du premier tour, puis le laisse dans le cache KV. Il attend le moteur, s'efface devant un tour utilisateur ou une génération en cours, se relance si preset/prompt/outils changent, et s'abstient en preset externe, cloud ou modèle brut. --- internal/ajean/backend_cloud.go | 4 + internal/ajean/chat_conversation.go | 8 + internal/ajean/llm_prewarm.go | 294 ++++++++++++++++++++++++++++ internal/ajean/llm_prewarm_test.go | 183 +++++++++++++++++ internal/ajean/web_api.go | 5 + internal/ajean/web_server.go | 20 ++ 6 files changed, 514 insertions(+) create mode 100644 internal/ajean/llm_prewarm.go create mode 100644 internal/ajean/llm_prewarm_test.go diff --git a/internal/ajean/backend_cloud.go b/internal/ajean/backend_cloud.go index 3a9e814c..35b1cc4d 100644 --- a/internal/ajean/backend_cloud.go +++ b/internal/ajean/backend_cloud.go @@ -471,6 +471,10 @@ func cloudEndpoint(cfg map[string]string) chatEndpoint { // doLLM envoie la requête ; pour un GPU cloud, attend le réveil du conteneur // (llama-server répond 503 « Loading model » tant que le modèle charge). func doLLM(ctx context.Context, req *http.Request, body []byte, ep chatEndpoint) (*http.Response, error) { + // Vu par le pré-chauffage de prompt (llm_prewarm.go) : une génération occupe + // le moteur, il ne faut pas s'insérer devant elle. + llmInFlight.Add(1) + defer llmInFlight.Add(-1) if !ep.Cloud { return http.DefaultClient.Do(req) } diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index bf62714a..31432d04 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -505,6 +505,9 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem } c.Generating = true c.genStart = time.Now() + // Le message de l'utilisateur passe devant tout pré-chauffage interne : le + // tour va de toute façon traiter le préfixe et garder le cache KV. + cancelLLMPrewarmForTurn() jeanReflectAbort() // le moteur est à l'utilisateur, la révision attendra ctx, cancel := context.WithCancel(context.Background()) c.cancel = cancel @@ -579,6 +582,10 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem if temperature == 0 { temperature = 0.7 } + // Un tour utilisateur est en cours : le pré-chauffage s'efface, y compris + // pendant les trous entre deux appels d'outil (llmTurnsActive, voir + // llm_prewarm.go). + llmTurnsActive.Add(1) go c.generate(ctx, caps, temperature, epoch) return nil } @@ -685,6 +692,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // donc les vieux tours affichaient le preset du moment, pas celui qui a répondu). turnPreset := activePresetName() defer func() { + llmTurnsActive.Add(-1) c.mu.Lock() stale := c.epoch != epoch // ⚠️ Un tour périmé ne touche PAS à l'état courant. Depuis que Reset diff --git a/internal/ajean/llm_prewarm.go b/internal/ajean/llm_prewarm.go new file mode 100644 index 00000000..61461e30 --- /dev/null +++ b/internal/ajean/llm_prewarm.go @@ -0,0 +1,294 @@ +package ajean + +// llm_prewarm.go — pré-chauffage du PRÉFIXE de prompt auprès du moteur. +// +// Le premier message d'une conversation paie le préfill de tout ce qui précède +// le texte de l'utilisateur : préambule système, briefing machine, prompt du +// preset et surtout les schémas d'outils — plusieurs milliers de jetons. Sur un +// modèle local, c'est l'essentiel du délai avant le premier jeton, et ce préfixe +// est IDENTIQUE d'une conversation à l'autre (le contexte projet, lui, part dans +// le premier message utilisateur, voir normalizeSystemMessages). +// +// Dès que le moteur répond, on envoie donc une complétion minuscule +// (max_tokens=1) avec EXACTEMENT ce préfixe. llama-server garde le cache KV du +// créneau (--cache-ram / --cache-idle-slots sont actifs par défaut) : le premier +// vrai message ne traite plus que son propre texte, et le premier jeton arrive +// tout de suite. Rien n'est affiché : la requête est interne, sa réponse jetée. +// +// Le pré-chauffage est BÉNIN : jamais pendant une génération (le compteur +// llmInFlight le dit), annulé dès qu'un tour utilisateur démarre (le tour +// chauffera le cache lui-même), deux tentatives au plus, signature mémorisée +// pour ne pas rejouer le même préfixe à chaque sondage de /api/status. + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + // Texte du faux message utilisateur : il ne sert qu'à rendre le prompt + // valide pour tous les gabarits de chat. Seul le préfixe qui le précède + // nous intéresse. + llmPrewarmUserText = "ok" + // Borne : sur un gros modèle CPU le préfill peut être long, mais au-delà le + // pré-chauffage retarderait le premier vrai message pour rien. + llmPrewarmTimeout = 30 * time.Second + // Deux essais suffisent (moteur ancien qui refuserait le corps, coupure…). + // Ensuite on abandonne jusqu'au prochain redémarrage du moteur ou changement + // de préfixe. + llmPrewarmMaxTries = 2 + // Une fois le préfixe chauffé, on revérifie quand même de temps en temps + // qu'il n'a pas changé (prompt du preset édité, outils activés…) — sans + // reconstruire le préfixe à chaque sondage de /api/status (toutes les 5 s). + llmPrewarmRecheck = 15 * time.Second +) + +// llmInFlight compte les complétions réellement en cours vers le moteur (chat, +// tâches, Jean, apps…). Sert au pré-chauffage : inutile — et nuisible — de +// s'insérer devant une génération déjà lancée. +var llmInFlight atomic.Int32 + +// llmTurnsActive couvre les TROUS entre deux requêtes d'un même tour (exécution +// d'un outil, lecture de fichier…) : llmInFlight retombe à zéro alors que le tour +// n'est pas fini et qu'un pré-chauffage s'intercalerait devant l'étape suivante. +// Compteur (pas un booléen) : un Reset peut lancer le tour suivant avant que le +// tour abandonné ne se termine ; sa décrémentation ne doit pas libérer le tour +// vivant. +var llmTurnsActive atomic.Int32 + +// llmPrewarmState : un seul pré-chauffage à la fois, et un souvenir de ce qui a +// déjà été chauffé pour ne pas le refaire à chaque sondage. +var llmWarm struct { + mu sync.Mutex + healthy bool // dernier état de santé observé (détecte un redémarrage moteur) + sig string + warmed bool + inflight bool + attempts int + nextTry time.Time + lastCheck time.Time + cancel context.CancelFunc +} + +// prewarmPrefix construit le préfixe commun à toute conversation : le bloc +// système tel qu'InjectSkills + normalizeSystemMessages le produisent, et les +// outils tels que runChat les annonce. Le contexte projet est volontairement +// absent (il vit dans le premier message utilisateur). +// +// Renvoie ok=false quand il n'y a rien à chauffer (modèle brut, sans système ni +// outil) : envoyer une requête ne gagnerait rien. +func prewarmPrefix(caps Caps) (system string, tools []Tool, ok bool) { + var parts []string + if bp := baseSystemPrompt(caps); bp != "" { + parts = append(parts, bp) + } + if mp := machineSystemPrompt(caps); mp != "" { + parts = append(parts, mp) + } + // Même ordre que generate : le prompt du preset est le premier message + // système, InjectSkills le fusionne APRÈS le préambule. + if sp := readSysPrompt(); sp != "" && caps.Agent && !caps.Terminal { + parts = append(parts, sp) + } + system = strings.Join(parts, "\n\n") + // Même liste que runChat : withoutRecallTools retire recall/recall_search + // tant que le fil n'a pas été compacté (c'est le cas d'une conversation + // neuve, celle qu'on pré-chauffe). + tools = withRecallTools(EnabledTools(caps), nil) + return system, tools, system != "" || len(tools) > 0 +} + +// prewarmSignature identifie le préfixe chauffé. Un changement de preset, de +// modèle, d'outil ou de prompt système doit relancer le pré-chauffage. +func prewarmSignature(ep chatEndpoint, system string, tools []Tool) string { + h := sha256.New() + h.Write([]byte(ep.URL)) + h.Write([]byte{0}) + h.Write([]byte(ep.Model)) + h.Write([]byte{0}) + h.Write([]byte(system)) + for _, t := range tools { + b, _ := json.Marshal(t) // ordre des champs fixe, clés de map triées + h.Write([]byte{0}) + h.Write(b) + } + return hex.EncodeToString(h.Sum(nil)[:12]) +} + +// prewarmPayload est le corps envoyé au moteur. Volontairement minimal : on ne +// veut qu'un préfill, pas une génération. +func prewarmPayload(ep chatEndpoint, system string, tools []Tool) map[string]any { + msgs := make([]Message, 0, 2) + if system != "" { + msgs = append(msgs, Message{Role: "system", Content: system}) + } + msgs = append(msgs, Message{Role: "user", Content: llmPrewarmUserText}) + p := map[string]any{ + "model": ep.Model, + "messages": msgs, + "max_tokens": 1, + "temperature": 0, + "stream": false, + "cache_prompt": true, + } + if len(tools) > 0 { + p["tools"] = tools + p["parallel_tool_calls"] = false + } + return p +} + +// LLMPrewarm doit être appelé à chaque fois que l'état du moteur est connu au +// service d'interface (démarrage de l'UI/link, sondage /api/status). Il est non +// bloquant : le pré-chauffage part dans sa propre goroutine. +// +// healthy=false signale un moteur qui ne répond pas encore : au prochain +// passage à true, le cache KV est forcément froid, on ré-arme. +func LLMPrewarm(healthy bool) { + // Phase 1 : état rapide, sans travail lourd sous le verrou. + llmWarm.mu.Lock() + if !healthy { + llmWarm.healthy = false + llmWarm.mu.Unlock() + return + } + if !llmWarm.healthy { + llmWarm.healthy = true + llmWarm.sig, llmWarm.warmed, llmWarm.attempts = "", false, 0 + llmWarm.lastCheck = time.Time{} + } + // Une génération réelle occupe le moteur : on ne se met pas devant elle. + // (Si le pré-chauffage est déjà parti, le tour le coupera — voir StartTurn.) + if llmInFlight.Load() > 0 || llmTurnsActive.Load() > 0 || llmWarm.inflight { + llmWarm.mu.Unlock() + return + } + if llmWarm.warmed && time.Since(llmWarm.lastCheck) < llmPrewarmRecheck { + llmWarm.mu.Unlock() + return + } + // Après un échec, on laisse passer le recul avant de reconstruire le préfixe + // (connexions MCP, sondage Crawl4AI) : rien ne doit tourner en boucle. + if !llmWarm.warmed && time.Now().Before(llmWarm.nextTry) { + llmWarm.mu.Unlock() + return + } + llmWarm.lastCheck = time.Now() + llmWarm.mu.Unlock() + + // Phase 2 : construction du préfixe HORS verrou. Elle peut être lente (les + // serveurs MCP se connectent, Crawl4AI est sondé) et ne doit jamais retarder + // cancelLLMPrewarmForTurn, appelé au démarrage d'un tour utilisateur. + cfg := ReadConfig() + if isExternalConfig(cfg) || isCloudConfig(cfg) { + return + } + ep := resolveChatEndpoint() + if ep.URL == "" { + return + } + system, tools, ok := prewarmPrefix(globalCaps()) + if !ok { + return + } + sig := prewarmSignature(ep, system, tools) + + // Phase 3 : décision et lancement, sous verrou, en revérifiant l'état (un + // tour a pu démarrer pendant la phase 2). + llmWarm.mu.Lock() + if llmWarm.sig != sig { + llmWarm.sig, llmWarm.warmed, llmWarm.attempts = sig, false, 0 + } + if llmWarm.warmed || llmWarm.inflight || llmInFlight.Load() > 0 || llmTurnsActive.Load() > 0 || + llmWarm.attempts >= llmPrewarmMaxTries || time.Now().Before(llmWarm.nextTry) { + llmWarm.mu.Unlock() + return + } + ctx, cancel := context.WithTimeout(context.Background(), llmPrewarmTimeout) + llmWarm.cancel = cancel + llmWarm.inflight = true + llmWarm.mu.Unlock() + + payload := prewarmPayload(ep, system, tools) + go func(sig string) { + err := sendLLMPrewarm(ctx, ep, payload) + cancel() + llmWarm.mu.Lock() + llmWarm.inflight = false + llmWarm.cancel = nil + // Un autre préfixe a pu prendre la main pendant l'envoi (changement de + // preset, de prompt système…) : son état ne doit pas être écrasé par le + // résultat de l'ancien. + if llmWarm.sig == sig { + if err != nil { + llmWarm.attempts++ + // Recul : un moteur qui refuse ce corps n'a pas besoin d'être + // resondé toutes les deux secondes. + llmWarm.nextTry = time.Now().Add(time.Duration(llmWarm.attempts) * 15 * time.Second) + } else { + llmWarm.warmed = true + llmWarm.attempts = 0 + } + } + llmWarm.mu.Unlock() + }(sig) +} + +// cancelLLMPrewarmForTurn coupe un pré-chauffage en cours quand un tour +// utilisateur démarre : sa demande passe devant tout travail interne. On note le +// cache comme chauffé — le tour va de toute façon traiter (et garder) le préfixe. +func cancelLLMPrewarmForTurn() { + llmWarm.mu.Lock() + cancel := llmWarm.cancel + if cancel != nil { + llmWarm.cancel = nil + llmWarm.warmed = true + } + llmWarm.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// sendLLMPrewarm envoie la requête de pré-chauffage et draine la réponse (pour +// laisser la connexion réutilisable par le vrai message). +func sendLLMPrewarm(ctx context.Context, ep chatEndpoint, payload map[string]any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ep.URL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if ep.External { + if ep.Key != "" { + req.Header.Set("Authorization", "Bearer "+ep.Key) + } + } else { + authHeader(req) + } + // Client par défaut : même pool de connexions que le chat, donc le premier + // message réutilise la connexion déjà ouverte. + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pré-chauffage : HTTP %d", resp.StatusCode) + } + return nil +} diff --git a/internal/ajean/llm_prewarm_test.go b/internal/ajean/llm_prewarm_test.go new file mode 100644 index 00000000..cca66875 --- /dev/null +++ b/internal/ajean/llm_prewarm_test.go @@ -0,0 +1,183 @@ +package ajean + +import ( + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Le pré-chauffage ne vaut que si son préfixe est EXACTEMENT celui du vrai tour. +// Sinon le cache KV de llama-server ne se raccorde pas et on paie un préfill pour +// rien. Ce test verrouille l'invariant : le bloc système et les outils construits +// par prewarmPrefix doivent être identiques à ce que InjectSkills + +// normalizeSystemMessages + runChat produisent pour une conversation neuve. +func TestPrewarmPrefixMatchesChatPrefix(t *testing.T) { + t.Setenv("AJEAN_HOME", t.TempDir()) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("MEM_MODE", "always"); err != nil { + t.Fatal(err) + } + // Prompt de preset : c'est le cas le plus courant, et il est fusionné APRÈS + // le préambule par InjectSkills. + const presetPrompt = "Réponds en français, sans préambule." + if err := putStr(bkState, "sysprompt", presetPrompt); err != nil { + t.Fatal(err) + } + + caps := Caps{Agent: true, Mem: MemAlways} + wantSystem, wantTools, ok := prewarmPrefix(caps) + if !ok { + t.Fatal("prewarmPrefix ne propose rien alors que l'agent et la mémoire sont actifs") + } + if wantSystem == "" || len(wantTools) == 0 { + t.Fatalf("préfixe vide : system=%q tools=%d", wantSystem, len(wantTools)) + } + + // Vue modèle réelle d'un premier message (voir Conversation.generate). + final := InjectSkills([]Message{ + {Role: "system", Content: readSysPrompt()}, + {Role: "user", Content: "bonjour"}, + }, caps) + final = normalizeSystemMessages(final) + if len(final) == 0 || final[0].Role != "system" { + t.Fatalf("le vrai tour ne commence pas par un system : %+v", final) + } + gotSystem, _ := final[0].Content.(string) + if gotSystem != wantSystem { + t.Fatalf("préfixe système différent du vrai tour\n--- pré-chauffé ---\n%s\n--- réel ---\n%s", wantSystem, gotSystem) + } + + // Avec un fil neuf, runChat retire recall/recall_search : même liste. + gotTools := withRecallTools(EnabledTools(caps), nil) + gb, _ := json.Marshal(gotTools) + wb, _ := json.Marshal(wantTools) + if string(gb) != string(wb) { + t.Fatalf("outils du pré-chauffage différents du vrai tour\n--- pré-chauffé ---\n%s\n--- réel ---\n%s", wb, gb) + } +} + +// Modèle brut (ni agent ni mémoire) : aucun préfixe à chauffer, on ne doit pas +// envoyer de requête inutile au moteur. +func TestPrewarmPrefixEmptyForRawModel(t *testing.T) { + t.Setenv("AJEAN_HOME", t.TempDir()) + if err := setAgentEnabled(false); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("MEM_MODE", "off"); err != nil { + t.Fatal(err) + } + if _, _, ok := prewarmPrefix(Caps{Mem: MemOff}); ok { + t.Fatal("pré-chauffage proposé pour un modèle brut, sans système ni outil") + } +} + +// La requête envoyée doit être minimale (un seul jeton) et porter le préfixe. +func TestPrewarmPayload(t *testing.T) { + ep := chatEndpoint{URL: "http://127.0.0.1:1/v1/chat/completions", Model: "ajean"} + tools := []Tool{bashTool()} + p := prewarmPayload(ep, "SYSTEM", tools) + if p["max_tokens"] != 1 { + t.Fatalf("max_tokens = %v, attendu 1", p["max_tokens"]) + } + if p["stream"] != false { + t.Fatal("le pré-chauffage doit être non streamé") + } + if p["cache_prompt"] != true { + t.Fatal("cache_prompt doit être vrai pour réutiliser le préfixe") + } + msgs, _ := p["messages"].([]Message) + if len(msgs) != 2 || msgs[0].Role != "system" || msgs[0].Content != "SYSTEM" || msgs[1].Role != "user" { + t.Fatalf("messages inattendus : %+v", msgs) + } + if _, ok := p["tools"].([]Tool); !ok { + t.Fatalf("outils absents du corps : %T", p["tools"]) + } + + // Sans système ni outils, on n'envoie que le message utilisateur minimal. + p = prewarmPayload(ep, "", nil) + msgs, _ = p["messages"].([]Message) + if len(msgs) != 1 || msgs[0].Role != "user" { + t.Fatalf("messages inattendus sans préfixe : %+v", msgs) + } + if _, ok := p["tools"]; ok { + t.Fatal("la clé tools ne doit pas être envoyée quand il n'y a aucun outil") + } +} + +// Bout en bout : LLMPrewarm envoie une requête au moteur, mémorise le préfixe +// chauffé et ne le rejoue pas au sondage suivant. +func TestLLMPrewarmSendsOnce(t *testing.T) { + t.Setenv("AJEAN_HOME", t.TempDir()) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("MEM_MODE", "on"); err != nil { + t.Fatal(err) + } + + got := make(chan map[string]any, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + http.NotFound(w, r) + return + } + var body map[string]any + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &body) + got <- body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`)) + })) + defer srv.Close() + _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } + // Réinitialise l'état global du pré-chauffage (les tests partagent le process). + llmWarm.mu.Lock() + llmWarm.healthy, llmWarm.sig, llmWarm.warmed, llmWarm.inflight = false, "", false, false + llmWarm.attempts, llmWarm.nextTry, llmWarm.cancel = 0, time.Time{}, nil + llmWarm.mu.Unlock() + t.Cleanup(func() { + llmWarm.mu.Lock() + llmWarm.healthy, llmWarm.sig, llmWarm.warmed, llmWarm.inflight = false, "", false, false + llmWarm.attempts, llmWarm.nextTry, llmWarm.cancel = 0, time.Time{}, nil + llmWarm.mu.Unlock() + }) + + LLMPrewarm(true) + select { + case body := <-got: + if body["max_tokens"] != float64(1) { + t.Fatalf("max_tokens = %v, attendu 1", body["max_tokens"]) + } + case <-time.After(5 * time.Second): + t.Fatal("aucune requête de pré-chauffage reçue") + } + // Laisse la goroutine refermer son état (inflight) avant le second appel. + deadline := time.Now().Add(2 * time.Second) + for { + llmWarm.mu.Lock() + inflight := llmWarm.inflight + llmWarm.mu.Unlock() + if !inflight || time.Now().After(deadline) { + break + } + time.Sleep(10 * time.Millisecond) + } + LLMPrewarm(true) + select { + case <-got: + t.Fatal("le même préfixe a été pré-chauffé deux fois") + case <-time.After(300 * time.Millisecond): + } +} diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 396e7312..96b7e09a 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -38,6 +38,11 @@ func handleStatus(w http.ResponseWriter, r *http.Request) { health := false if active { health = healthCheck() + // Moteur prêt : on pré-chauffe le préfixe (système + outils) pour que le + // premier message d'une conversation ne paie plus son préfill. Détaché : + // la construction du préfixe peut sonder MCP/Crawl4AI, elle n'a rien à + // faire dans le temps de réponse de /api/status. + go LLMPrewarm(health) } // Preset externe : pas de service llama-server local, mais le chat est prêt // (il tape vers l'API distante). On l'annonce « actif/prêt » pour que l'UI diff --git a/internal/ajean/web_server.go b/internal/ajean/web_server.go index b7f3e09c..25823f2b 100644 --- a/internal/ajean/web_server.go +++ b/internal/ajean/web_server.go @@ -351,6 +351,26 @@ func newWebMux() *http.ServeMux { api("/api/tasks/pause", handleTasksPause) api("/api/tasks/run", handleTaskRun) api("/api/tasks/stop", handleTaskStop) + // Pré-chauffe le préfixe dès que le moteur répond, sans attendre le premier + // sondage du navigateur (ui/link peut tourner sans page ouverte). + // • déjà lancé (redémarrage de l'UI seule) : tout de suite ; + // • en cours de chargement (démarrage de la machine) : dès qu'il répond, + // pendant 10 min au plus. Un moteur volontairement arrêté ne coûte que + // des connexions refusées, sans journal. + go func() { + if healthCheck() { + LLMPrewarm(true) + return + } + deadline := time.Now().Add(10 * time.Minute) + for time.Now().Before(deadline) { + time.Sleep(3 * time.Second) + if healthCheck() { + LLMPrewarm(true) + return + } + } + }() StartTaskScheduler() return mux } From 2a3320748dac67d4285573f597cc99609f46ea8d Mon Sep 17 00:00:00 2001 From: youtsuho <110821381+youtsuhodev@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:29:43 +0200 Subject: [PATCH 2/3] Align LLM prewarming with chat mode Prewarm only empty conversations using the mode and capabilities that will handle the first message. Remember the last chat mode, skip warming when the target is unknown or mismatched, and guard against a conversation starting before the request is sent. Add tests for mode-specific prefixes and conversation safety. --- internal/ajean/llm_prewarm.go | 130 ++++++++- internal/ajean/llm_prewarm_test.go | 419 +++++++++++++++++++++++------ internal/ajean/web_chat.go | 3 + 3 files changed, 453 insertions(+), 99 deletions(-) diff --git a/internal/ajean/llm_prewarm.go b/internal/ajean/llm_prewarm.go index 61461e30..0236e9a4 100644 --- a/internal/ajean/llm_prewarm.go +++ b/internal/ajean/llm_prewarm.go @@ -6,8 +6,8 @@ package ajean // le texte de l'utilisateur : préambule système, briefing machine, prompt du // preset et surtout les schémas d'outils — plusieurs milliers de jetons. Sur un // modèle local, c'est l'essentiel du délai avant le premier jeton, et ce préfixe -// est IDENTIQUE d'une conversation à l'autre (le contexte projet, lui, part dans -// le premier message utilisateur, voir normalizeSystemMessages). +// est IDENTIQUE d'une conversation à l'autre d'un même mode (le contexte projet, +// lui, part dans le premier message utilisateur, voir normalizeSystemMessages). // // Dès que le moteur répond, on envoie donc une complétion minuscule // (max_tokens=1) avec EXACTEMENT ce préfixe. llama-server garde le cache KV du @@ -15,6 +15,19 @@ package ajean // vrai message ne traite plus que son propre texte, et le premier jeton arrive // tout de suite. Rien n'est affiché : la requête est interne, sa réponse jetée. // +// Deux règles tiennent ce pré-chauffage honnête : +// +// 1. Il ne part QUE si la conversation active est vide. Avec un seul créneau de +// cache (-np 1, le cas courant), chauffer pendant qu'une conversation est en +// cours écraserait son cache KV : le message suivant repaierait tout +// l'historique. Une conversation commencée (ou en génération) n'est jamais +// touchée, quoi qu'il arrive au prompt, aux outils ou à l'interface. +// 2. Le préfixe est celui du MODE qui recevra le premier message (Jean, Projet, +// Rapide, Modèle de base), construit par les mêmes capacités que le vrai envoi +// (capsFromBody), pas par les réglages globaux de la machine : sinon le +// préfixe chauffé ne correspond pas au vrai premier tour et le cache ne se +// raccorde pas. Voir prewarmTarget pour la façon dont le mode est connu. +// // Le pré-chauffage est BÉNIN : jamais pendant une génération (le compteur // llmInFlight le dit), annulé dès qu'un tour utilisateur démarre (le tour // chauffera le cache lui-même), deux tentatives au plus, signature mémorisée @@ -51,6 +64,9 @@ const ( // qu'il n'a pas changé (prompt du preset édité, outils activés…) — sans // reconstruire le préfixe à chaque sondage de /api/status (toutes les 5 s). llmPrewarmRecheck = 15 * time.Second + // Clé (bucket state) du dernier mode de chat réellement utilisé à l'envoi. + // Sert à deviner le mode d'une conversation encore vide (voir prewarmTarget). + lastChatModeKey = "last_chat_mode" ) // llmInFlight compte les complétions réellement en cours vers le moteur (chat, @@ -68,6 +84,10 @@ var llmTurnsActive atomic.Int32 // llmPrewarmState : un seul pré-chauffage à la fois, et un souvenir de ce qui a // déjà été chauffé pour ne pas le refaire à chaque sondage. +// +// ⚠️ Ordre des verrous : conv.mu PUIS llmWarm.mu (StartTurn appelle +// cancelLLMPrewarmForTurn en tenant conv.mu). Ne jamais lire la conversation +// (prewarmTarget) en tenant llmWarm.mu. var llmWarm struct { mu sync.Mutex healthy bool // dernier état de santé observé (détecte un redémarrage moteur) @@ -80,10 +100,76 @@ var llmWarm struct { cancel context.CancelFunc } -// prewarmPrefix construit le préfixe commun à toute conversation : le bloc -// système tel qu'InjectSkills + normalizeSystemMessages le produisent, et les -// outils tels que runChat les annonce. Le contexte projet est volontairement -// absent (il vit dans le premier message utilisateur). +// prewarmModeValid : modes de chat que le serveur connaît (voir requestedMode). +func prewarmModeValid(mode string) bool { + switch mode { + case "project", "fast", "base", "jean": + return true + } + return false +} + +// rememberChatMode retient le mode du dernier message envoyé. Le navigateur ne +// dit son mode au serveur qu'avec le premier message d'une conversation +// (Conversation.lockMode) : tant qu'elle est vide, le dernier mode utilisé est la +// meilleure information disponible — on ne change guère de mode d'un message à +// l'autre. N'écrit que si la valeur change. +func rememberChatMode(mode string) { + if prewarmModeValid(mode) && getStr(bkState, lastChatModeKey) != mode { + _ = putStr(bkState, lastChatModeKey, mode) + } +} + +// prewarmCaps : capacités du mode, construites comme pour un vrai envoi +// (handleChatSend → applyChatMode + capsFromBody). Ce sont elles — et non +// globalCaps() — qui décident du préambule, du briefing machine, du prompt du +// preset et des outils du premier tour : Rapide n'a ni mémoire ni prompt de +// preset, Jean a ses outils et son préambule, Modèle de base n'a rien. +func prewarmCaps(mode string) Caps { + var b chatReq + applyChatMode(&b, mode) + return capsFromBody(b) +} + +// prewarmTarget dit si le pré-chauffage a le droit de partir, et pour quel mode. +// +// ok=false (rien à faire) quand : +// - la conversation active n'est pas vide ou génère : son cache KV est à elle, +// et le créneau est unique avec -np 1 ; +// - le mode du prochain message est inconnu : on ne devine pas, un préfixe +// chauffé pour rien coûte un préfill et ne raccorde pas le vrai tour ; +// - le mode ne correspond pas à la conversation ouverte : Jean est une +// conversation unique et persistante (jeanConvID), les autres modes n'y +// écrivent jamais. Dans ce cas, le premier message ouvrira une AUTRE +// conversation (alignConvForMode) dont on ne sait pas si elle est vide. +// +// Le mode est celui de la conversation s'il est déjà fixé (Jean), sinon celui du +// dernier message envoyé (rememberChatMode). +func prewarmTarget() (mode string, ok bool) { + conv.mu.Lock() + id, cmode := conv.ID, conv.Mode + idle := len(conv.Messages) == 0 && !conv.Generating + conv.mu.Unlock() + if !idle { + return "", false + } + mode = cmode + if mode == "" { + mode = getStr(bkState, lastChatModeKey) + } + if !prewarmModeValid(mode) { + return "", false + } + if (mode == "jean") != (id == jeanConvID) { + return "", false + } + return mode, true +} + +// prewarmPrefix construit le préfixe commun à toute conversation du mode décrit +// par caps : le bloc système tel qu'InjectSkills + normalizeSystemMessages le +// produisent, et les outils tels que runChat les annonce. Le contexte projet est +// volontairement absent (il vit dans le premier message utilisateur). // // Renvoie ok=false quand il n'y a rien à chauffer (modèle brut, sans système ni // outil) : envoyer une requête ne gagnerait rien. @@ -155,6 +241,12 @@ func prewarmPayload(ep chatEndpoint, system string, tools []Tool) map[string]any // healthy=false signale un moteur qui ne répond pas encore : au prochain // passage à true, le cache KV est forcément froid, on ré-arme. func LLMPrewarm(healthy bool) { + // La conversation est lue AVANT de prendre llmWarm.mu (ordre des verrous, voir + // llmWarm). Elle décide de tout : sans conversation active vide, on ne touche + // pas au créneau de cache — mais l'état de santé, lui, est suivi quand même + // (un redémarrage du moteur doit ré-armer, même pendant une conversation). + mode, canWarm := prewarmTarget() + // Phase 1 : état rapide, sans travail lourd sous le verrou. llmWarm.mu.Lock() if !healthy { @@ -167,6 +259,10 @@ func LLMPrewarm(healthy bool) { llmWarm.sig, llmWarm.warmed, llmWarm.attempts = "", false, 0 llmWarm.lastCheck = time.Time{} } + if !canWarm { + llmWarm.mu.Unlock() + return + } // Une génération réelle occupe le moteur : on ne se met pas devant elle. // (Si le pré-chauffage est déjà parti, le tour le coupera — voir StartTurn.) if llmInFlight.Load() > 0 || llmTurnsActive.Load() > 0 || llmWarm.inflight { @@ -197,7 +293,7 @@ func LLMPrewarm(healthy bool) { if ep.URL == "" { return } - system, tools, ok := prewarmPrefix(globalCaps()) + system, tools, ok := prewarmPrefix(prewarmCaps(mode)) if !ok { return } @@ -221,15 +317,29 @@ func LLMPrewarm(healthy bool) { payload := prewarmPayload(ep, system, tools) go func(sig string) { - err := sendLLMPrewarm(ctx, ep, payload) + // Dernier garde-fou, APRÈS l'enregistrement de cancel ci-dessus : la + // conversation a pu recevoir un message (ou un autre mode a pu s'ouvrir) + // depuis la lecture du début. StartTurn pose Generating sous conv.mu AVANT + // d'appeler cancelLLMPrewarmForTurn, donc deux cas seulement : soit le + // tour a démarré avant, on le voit ici et on n'envoie rien ; soit il + // démarre après, et son cancelLLMPrewarmForTurn coupe cette requête. + skipped := false + var err error + if m, ok := prewarmTarget(); !ok || m != mode { + skipped = true + } else { + err = sendLLMPrewarm(ctx, ep, payload) + } cancel() llmWarm.mu.Lock() llmWarm.inflight = false llmWarm.cancel = nil // Un autre préfixe a pu prendre la main pendant l'envoi (changement de // preset, de prompt système…) : son état ne doit pas être écrasé par le - // résultat de l'ancien. - if llmWarm.sig == sig { + // résultat de l'ancien. Une requête non envoyée (conversation devenue + // active) ne compte ni comme essai ni comme succès : on réessaiera quand + // une conversation vide sera de nouveau ouverte. + if llmWarm.sig == sig && !skipped { if err != nil { llmWarm.attempts++ // Recul : un moteur qui refuse ce corps n'a pas besoin d'être diff --git a/internal/ajean/llm_prewarm_test.go b/internal/ajean/llm_prewarm_test.go index cca66875..439f45b4 100644 --- a/internal/ajean/llm_prewarm_test.go +++ b/internal/ajean/llm_prewarm_test.go @@ -10,70 +10,252 @@ import ( "time" ) -// Le pré-chauffage ne vaut que si son préfixe est EXACTEMENT celui du vrai tour. -// Sinon le cache KV de llama-server ne se raccorde pas et on paie un préfill pour -// rien. Ce test verrouille l'invariant : le bloc système et les outils construits -// par prewarmPrefix doivent être identiques à ce que InjectSkills + -// normalizeSystemMessages + runChat produisent pour une conversation neuve. -func TestPrewarmPrefixMatchesChatPrefix(t *testing.T) { - t.Setenv("AJEAN_HOME", t.TempDir()) - if err := setAgentEnabled(true); err != nil { +// engineSpy est un faux llama-server : /health répond, et chaque complétion reçue +// est transmise au test. Une complétion en STREAMING (un vrai tour) reste ouverte +// jusqu'à ce que le test interrompe le tour (conv.Stop) ; une complétion non +// streamée (le pré-chauffage) reçoit une réponse vide tout de suite. +type engineSpy struct { + got chan map[string]any +} + +func newEngineSpy(t *testing.T) *engineSpy { + t.Helper() + s := &engineSpy{got: make(chan map[string]any, 16)} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/v1/chat/completions": + var body map[string]any + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &body) + select { + case s.got <- body: + default: + } + if stream, _ := body["stream"].(bool); stream { + select { + case <-r.Context().Done(): + case <-time.After(10 * time.Second): + } + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) + if err != nil { t.Fatal(err) } - if err := SetConfigKey("MEM_MODE", "always"); err != nil { + if err := SetConfigKey("PORT", port); err != nil { t.Fatal(err) } - // Prompt de preset : c'est le cas le plus courant, et il est fusionné APRÈS - // le préambule par InjectSkills. - const presetPrompt = "Réponds en français, sans préambule." - if err := putStr(bkState, "sysprompt", presetPrompt); err != nil { - t.Fatal(err) + return s +} + +// next attend la prochaine complétion reçue par le faux moteur. +func (s *engineSpy) next(t *testing.T) map[string]any { + t.Helper() + select { + case b := <-s.got: + return b + case <-time.After(5 * time.Second): + t.Fatal("aucune requête reçue par le moteur") + return nil } +} - caps := Caps{Agent: true, Mem: MemAlways} - wantSystem, wantTools, ok := prewarmPrefix(caps) - if !ok { - t.Fatal("prewarmPrefix ne propose rien alors que l'agent et la mémoire sont actifs") +// none vérifie qu'aucune complétion n'arrive pendant d. +func (s *engineSpy) none(t *testing.T, d time.Duration, why string) { + t.Helper() + select { + case b := <-s.got: + t.Fatalf("%s : le moteur a reçu une requête (%v)", why, b["max_tokens"]) + case <-time.After(d): } - if wantSystem == "" || len(wantTools) == 0 { - t.Fatalf("préfixe vide : system=%q tools=%d", wantSystem, len(wantTools)) +} + +// waitConvIdle attend la fin du tour en cours sur la conversation vive. +func waitConvIdle(t *testing.T) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for conv.isGenerating() { + if time.Now().After(deadline) { + t.Fatal("le tour ne s'est pas terminé") + } + time.Sleep(10 * time.Millisecond) } +} - // Vue modèle réelle d'un premier message (voir Conversation.generate). - final := InjectSkills([]Message{ - {Role: "system", Content: readSysPrompt()}, - {Role: "user", Content: "bonjour"}, - }, caps) - final = normalizeSystemMessages(final) - if len(final) == 0 || final[0].Role != "system" { - t.Fatalf("le vrai tour ne commence pas par un system : %+v", final) +// resetConvEmpty remet la conversation vive (globale au process) dans l'état d'une +// conversation neuve. mode "jean" = la conversation unique de Jean. +func resetConvEmpty(mode string) { + conv.mu.Lock() + defer conv.mu.Unlock() + conv.Messages, conv.Log, conv.queued = nil, nil, nil + conv.Seq, conv.CtxUsed = 0, 0 + conv.Generating = false + conv.cancel = nil + conv.epoch++ + conv.ID, conv.Mode = newSessionID(), "" + if mode == "jean" { + conv.ID, conv.Mode = jeanConvID, "jean" } - gotSystem, _ := final[0].Content.(string) - if gotSystem != wantSystem { - t.Fatalf("préfixe système différent du vrai tour\n--- pré-chauffé ---\n%s\n--- réel ---\n%s", wantSystem, gotSystem) +} + +// setConvEmpty : conversation neuve pour ce test, et remise à zéro à sa fin (les +// tests partagent le process, donc la conversation globale). +func setConvEmpty(t *testing.T, mode string) { + t.Helper() + resetConvEmpty(mode) + t.Cleanup(func() { + conv.Stop() + waitConvIdle(t) + resetConvEmpty("") + }) +} + +// resetWarmState réinitialise l'état global du pré-chauffage (idem : partagé). +func resetWarmState(t *testing.T) { + t.Helper() + reset := func() { + llmWarm.mu.Lock() + llmWarm.healthy, llmWarm.sig, llmWarm.warmed, llmWarm.inflight = false, "", false, false + llmWarm.attempts, llmWarm.nextTry, llmWarm.lastCheck, llmWarm.cancel = 0, time.Time{}, time.Time{}, nil + llmWarm.mu.Unlock() } + reset() + t.Cleanup(reset) +} + +// canon normalise une valeur JSON (clés triées) pour comparer des corps de requête +// venus de deux chemins différents. +func canon(v any) string { + b, _ := json.Marshal(v) + var x any + _ = json.Unmarshal(b, &x) + b, _ = json.Marshal(x) + return string(b) +} + +// Le pré-chauffage ne vaut que si son préfixe est EXACTEMENT celui du vrai tour. +// Sinon le cache KV de llama-server ne se raccorde pas et on paie un préfill pour +// rien. Ce test ne compare donc PAS le pré-chauffage à ses propres fonctions : il +// lance un vrai premier message (StartTurn → generate → runChat) contre un faux +// moteur, relève le corps réellement envoyé, et exige que le bloc système et les +// outils du pré-chauffage du même mode lui soient identiques — pour chaque mode. +func TestPrewarmPrefixMatchesGenerate(t *testing.T) { + // Prompt de preset : c'est le cas le plus courant en Projet et Jean, et il est + // fusionné APRÈS le préambule par InjectSkills. Rapide ne l'utilise pas. + const presetPrompt = "Réponds en français, sans préambule." + + for _, mode := range []string{"project", "fast", "jean"} { + t.Run(mode, func(t *testing.T) { + testHome(t) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("MEM_MODE", "always"); err != nil { + t.Fatal(err) + } + if err := putStr(bkState, "sysprompt", presetPrompt); err != nil { + t.Fatal(err) + } + spy := newEngineSpy(t) + setConvEmpty(t, mode) + rememberChatMode(mode) + + // Ce que le pré-chauffage enverrait pour la conversation vide ouverte. + target, ok := prewarmTarget() + if !ok || target != mode { + t.Fatalf("prewarmTarget = %q, %v ; attendu %q", target, ok, mode) + } + system, tools, ok := prewarmPrefix(prewarmCaps(target)) + if !ok || system == "" || len(tools) == 0 { + t.Fatalf("préfixe vide en mode %s : system=%q tools=%d", mode, system, len(tools)) + } + want := prewarmPayload(resolveChatEndpoint(), system, tools) - // Avec un fil neuf, runChat retire recall/recall_search : même liste. - gotTools := withRecallTools(EnabledTools(caps), nil) - gb, _ := json.Marshal(gotTools) - wb, _ := json.Marshal(wantTools) - if string(gb) != string(wb) { - t.Fatalf("outils du pré-chauffage différents du vrai tour\n--- pré-chauffé ---\n%s\n--- réel ---\n%s", wb, gb) + // Le vrai premier message, tel qu'handleChatSend le lance. + body := chatReq{Mode: mode} + m, _ := conv.lockMode(requestedMode(body)) + applyChatMode(&body, m) + if err := conv.StartTurn("bonjour", nil, capsFromBody(body), 0.7); err != nil { + t.Fatal(err) + } + sent := spy.next(t) + conv.Stop() // on a le corps : le tour n'a plus rien à faire + waitConvIdle(t) + + msgs, _ := sent["messages"].([]any) + if len(msgs) < 2 { + t.Fatalf("messages du vrai tour : %v", sent["messages"]) + } + first, _ := msgs[0].(map[string]any) + if first["role"] != "system" { + t.Fatalf("le vrai tour ne commence pas par un system : %v", first) + } + wantMsgs, _ := want["messages"].([]Message) + if got, exp := first["content"], wantMsgs[0].Content; got != exp { + t.Fatalf("bloc système différent du vrai tour\n--- pré-chauffé ---\n%v\n--- réel ---\n%v", exp, got) + } + if got, exp := canon(sent["tools"]), canon(want["tools"]); got != exp { + t.Fatalf("outils différents du vrai tour\n--- pré-chauffés ---\n%s\n--- réels ---\n%s", exp, got) + } + }) } } -// Modèle brut (ni agent ni mémoire) : aucun préfixe à chauffer, on ne doit pas -// envoyer de requête inutile au moteur. +// Les modes ne partagent pas leur préfixe : c'est ce qu'ignorait un préfixe bâti +// avec les réglages globaux de la machine. Rapide n'a ni mémoire ni prompt de +// preset, Jean a ses outils et son préambule. +func TestPrewarmPrefixDiffersByMode(t *testing.T) { + testHome(t) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("MEM_MODE", "always"); err != nil { + t.Fatal(err) + } + if err := putStr(bkState, "sysprompt", "Prompt de preset."); err != nil { + t.Fatal(err) + } + sig := map[string]string{} + for _, mode := range []string{"project", "fast", "jean"} { + system, tools, ok := prewarmPrefix(prewarmCaps(mode)) + if !ok { + t.Fatalf("mode %s : rien à chauffer", mode) + } + sig[mode] = prewarmSignature(chatEndpoint{URL: "u", Model: "m"}, system, tools) + } + if sig["project"] == sig["fast"] || sig["project"] == sig["jean"] || sig["fast"] == sig["jean"] { + t.Fatalf("des modes différents donnent le même préfixe : %v", sig) + } +} + +// Modèle de base (ni agent ni mémoire) : aucun préfixe à chauffer, on ne doit pas +// envoyer de requête inutile au moteur — même si l'agent de la machine est actif. func TestPrewarmPrefixEmptyForRawModel(t *testing.T) { - t.Setenv("AJEAN_HOME", t.TempDir()) + testHome(t) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if _, _, ok := prewarmPrefix(prewarmCaps("base")); ok { + t.Fatal("pré-chauffage proposé pour le modèle de base, sans système ni outil") + } + // Agent coupé sur la machine : plus rien à chauffer non plus, quel que soit le mode. if err := setAgentEnabled(false); err != nil { t.Fatal(err) } if err := SetConfigKey("MEM_MODE", "off"); err != nil { t.Fatal(err) } - if _, _, ok := prewarmPrefix(Caps{Mem: MemOff}); ok { - t.Fatal("pré-chauffage proposé pour un modèle brut, sans système ni outil") + if _, _, ok := prewarmPrefix(prewarmCaps("project")); ok { + t.Fatal("pré-chauffage proposé alors que l'agent est coupé") } } @@ -110,59 +292,122 @@ func TestPrewarmPayload(t *testing.T) { } } -// Bout en bout : LLMPrewarm envoie une requête au moteur, mémorise le préfixe -// chauffé et ne le rejoue pas au sondage suivant. -func TestLLMPrewarmSendsOnce(t *testing.T) { - t.Setenv("AJEAN_HOME", t.TempDir()) - if err := setAgentEnabled(true); err != nil { - t.Fatal(err) +// Seule une conversation active VIDE peut être pré-chauffée : avec un seul créneau +// de cache (-np 1), chauffer pendant une conversation écraserait son cache et le +// message suivant repaierait tout l'historique. +func TestPrewarmTarget(t *testing.T) { + testHome(t) + rememberChatMode("fast") + + // Conversation neuve, dernier mode connu : OK. + setConvEmpty(t, "") + if m, ok := prewarmTarget(); !ok || m != "fast" { + t.Fatalf("conversation vide : %q, %v", m, ok) } - if err := SetConfigKey("MEM_MODE", "on"); err != nil { + + // Conversation commencée : jamais. + conv.mu.Lock() + conv.Messages = []Message{{Role: "user", Content: "salut"}} + conv.mu.Unlock() + if m, ok := prewarmTarget(); ok { + t.Fatalf("conversation en cours pré-chauffée (%q)", m) + } + + // Tour en cours (ou tâche de fond qui tient le verrou de génération) : jamais. + resetConvEmpty("") + conv.mu.Lock() + conv.Generating = true + conv.mu.Unlock() + if m, ok := prewarmTarget(); ok { + t.Fatalf("génération en cours pré-chauffée (%q)", m) + } + + // Le mode de la conversation prime sur le dernier mode envoyé. + resetConvEmpty("") + conv.mu.Lock() + conv.Mode = "project" + conv.mu.Unlock() + if m, ok := prewarmTarget(); !ok || m != "project" { + t.Fatalf("mode de la conversation : %q, %v", m, ok) + } + + // Jean : sa conversation, vide, est chauffée avec SON préfixe… + resetConvEmpty("jean") + if m, ok := prewarmTarget(); !ok || m != "jean" { + t.Fatalf("Jean vide : %q, %v", m, ok) + } + // …mais pas quand Jean est le dernier mode et qu'une conversation ordinaire est + // ouverte (le premier message ouvrira la conversation de Jean, d'historique inconnu)… + rememberChatMode("jean") + resetConvEmpty("") + if m, ok := prewarmTarget(); ok { + t.Fatalf("conversation ordinaire pré-chauffée avec le mode Jean (%q)", m) + } + // …ni l'inverse : la conversation de Jean ouverte, un autre mode demandé. + rememberChatMode("fast") + resetConvEmpty("jean") + conv.mu.Lock() + conv.Mode = "" + conv.mu.Unlock() + if m, ok := prewarmTarget(); ok { + t.Fatalf("conversation de Jean pré-chauffée avec le mode %q", m) + } + + // Mode inconnu (rien envoyé encore) : on ne devine pas. + if err := putStr(bkState, lastChatModeKey, ""); err != nil { t.Fatal(err) } + resetConvEmpty("") + if m, ok := prewarmTarget(); ok { + t.Fatalf("mode inconnu pré-chauffé (%q)", m) + } +} - got := make(chan map[string]any, 4) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/chat/completions" { - http.NotFound(w, r) - return - } - var body map[string]any - b, _ := io.ReadAll(r.Body) - _ = json.Unmarshal(b, &body) - got <- body - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`)) - })) - defer srv.Close() - _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) - if err != nil { +// Bout en bout : LLMPrewarm n'envoie RIEN tant que la conversation est en cours, +// puis une requête quand une conversation vide est ouverte — et ne la rejoue pas +// au sondage suivant. +func TestLLMPrewarmWaitsForEmptyConversation(t *testing.T) { + testHome(t) + if err := setAgentEnabled(true); err != nil { t.Fatal(err) } - if err := SetConfigKey("PORT", port); err != nil { + if err := SetConfigKey("MEM_MODE", "on"); err != nil { t.Fatal(err) } - // Réinitialise l'état global du pré-chauffage (les tests partagent le process). - llmWarm.mu.Lock() - llmWarm.healthy, llmWarm.sig, llmWarm.warmed, llmWarm.inflight = false, "", false, false - llmWarm.attempts, llmWarm.nextTry, llmWarm.cancel = 0, time.Time{}, nil - llmWarm.mu.Unlock() - t.Cleanup(func() { - llmWarm.mu.Lock() - llmWarm.healthy, llmWarm.sig, llmWarm.warmed, llmWarm.inflight = false, "", false, false - llmWarm.attempts, llmWarm.nextTry, llmWarm.cancel = 0, time.Time{}, nil - llmWarm.mu.Unlock() - }) + spy := newEngineSpy(t) + resetWarmState(t) + rememberChatMode("project") + setConvEmpty(t, "") + // Conversation en cours : le moteur ne doit rien recevoir, même moteur sain. + conv.mu.Lock() + conv.Messages = []Message{{Role: "user", Content: "salut"}, {Role: "assistant", Content: "bonjour"}} + conv.mu.Unlock() LLMPrewarm(true) - select { - case body := <-got: - if body["max_tokens"] != float64(1) { - t.Fatalf("max_tokens = %v, attendu 1", body["max_tokens"]) - } - case <-time.After(5 * time.Second): - t.Fatal("aucune requête de pré-chauffage reçue") + spy.none(t, 300*time.Millisecond, "conversation en cours") + + // Redémarrage de l'UI / du moteur pendant la conversation : toujours rien. + LLMPrewarm(false) + LLMPrewarm(true) + spy.none(t, 300*time.Millisecond, "conversation en cours, moteur redémarré") + + // Nouvelle conversation, vide : cette fois on chauffe, avec le préfixe du mode. + resetConvEmpty("") + LLMPrewarm(true) + body := spy.next(t) + if body["max_tokens"] != float64(1) { + t.Fatalf("max_tokens = %v, attendu 1", body["max_tokens"]) + } + system, tools, _ := prewarmPrefix(prewarmCaps("project")) + msgs, _ := body["messages"].([]any) + first, _ := msgs[0].(map[string]any) + if first["role"] != "system" || first["content"] != system { + t.Fatalf("préfixe chauffé inattendu : %v", first) + } + if canon(body["tools"]) != canon(tools) { + t.Fatal("outils chauffés différents de ceux du mode") } + // Laisse la goroutine refermer son état (inflight) avant le second appel. deadline := time.Now().Add(2 * time.Second) for { @@ -175,9 +420,5 @@ func TestLLMPrewarmSendsOnce(t *testing.T) { time.Sleep(10 * time.Millisecond) } LLMPrewarm(true) - select { - case <-got: - t.Fatal("le même préfixe a été pré-chauffé deux fois") - case <-time.After(300 * time.Millisecond): - } + spy.none(t, 300*time.Millisecond, "même préfixe déjà chauffé") } diff --git a/internal/ajean/web_chat.go b/internal/ajean/web_chat.go index 05f9e4d9..0ba50046 100644 --- a/internal/ajean/web_chat.go +++ b/internal/ajean/web_chat.go @@ -155,6 +155,9 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) { } // Mode : celui de la conversation s'il est déjà fixé, sinon celui demandé. mode, fresh := conv.lockMode(requestedMode(body)) + // Dernier mode utilisé : le pré-chauffage s'en sert tant que la conversation + // suivante est vide (voir prewarmTarget). + rememberChatMode(mode) applyChatMode(&body, mode) if mode == "jean" { files = moveUploadsToJean(files) From 4d550ad905e638371901bd33581ded6c63887f1b Mon Sep 17 00:00:00 2001 From: youtsuho <110821381+youtsuhodev@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:37:18 +0200 Subject: [PATCH 3/3] Update llm_prewarm.go --- internal/ajean/llm_prewarm.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/ajean/llm_prewarm.go b/internal/ajean/llm_prewarm.go index 0236e9a4..4aa6046d 100644 --- a/internal/ajean/llm_prewarm.go +++ b/internal/ajean/llm_prewarm.go @@ -191,6 +191,12 @@ func prewarmPrefix(caps Caps) (system string, tools []Tool, ok bool) { // tant que le fil n'a pas été compacté (c'est le cas d'une conversation // neuve, celle qu'on pré-chauffe). tools = withRecallTools(EnabledTools(caps), nil) + // Mode Jean : runChat ajoute à chaque outil le champ « description » + // (chat_tool_desc.go). Sans lui, les schémas chauffés diffèrent de ceux du + // vrai tour et le cache ne se raccorde pas au-delà du bloc système. + if caps.Jean { + tools, _ = withToolDesc(tools) + } return system, tools, system != "" || len(tools) > 0 }