diff --git a/README.md b/README.md index 1dd10a5..02c40d7 100644 --- a/README.md +++ b/README.md @@ -433,6 +433,40 @@ Ajoutées par ce fork : vidé. Le filet réactif (prompt refusé), le bouton « compacter », les tâches, les sous-agents et un preset externe gardent le chemin d'avant. Visible dans la télémétrie sous `compact`. +- **Images d'outils gardées** (clé `KEEP_TURN_IMAGES`, **off** par défaut, + **zone grise**, `loki config set KEEP_TURN_IMAGES on`) : sans la clé, une + image montrée par un outil (`web_screenshot`, `browser_screenshot`, + `see_image`) n'est vue que pendant le tour ; au tour suivant elle manque à + l'historique, et toute la boucle d'outils qui la suivait — souvent des + dizaines d'étapes de navigation — est recalculée. Avec `on`, elle reste dans + l'historique telle qu'envoyée, rangée par référence (`chatimg/`, chiffrée si + la mémoire l'est), sous conditions : vision réellement active (projecteur + chargé, revérifié à chaque tour — un preset sans vision n'en reçoit jamais) ; + coût mesuré par le moteur (écart du nombre de jetons du prompt entre deux + requêtes), et une image qu'on ne sait pas mesurer reste éphémère ; toutes les + images gardées tiennent dans 10 % de la fenêtre, la première qui dépasse et + celles qui la suivent dans le tour restent éphémères. Plus d'images gardées, + c'est plus de contexte, donc une compaction (avec perte) plus tôt : toute + compaction ou réduction forcée retire ces images **d'abord** (leur légende et + une mention « image non gardée » restent) et ne résume ensuite que si c'est + encore nécessaire ; rouvrir la discussion les retire aussi, comme les pièces + jointes. Discussion seulement (ni tâche, ni sous-agent, ni vérification). Un + preset externe n'est concerné que si sa vision est déclarée, et l'API + refacture alors ces images à chaque tour. Sans la clé, la requête est + identique à l'octet près. +- **Rappel de budget dans le résultat d'outil** (clé `NUDGE_IN_TOOL`, **off** + par défaut, **zone grise**, `loki config set NUDGE_IN_TOOL on`) : après un + grand nombre d'appels d'outils dans un même tour, Loki rappelle au modèle de + conclure, dans un message à part. Sur un gabarit dont le rendu change dès + qu'un message s'ajoute (Qwen3.5 : ce message devient la « dernière question » + et tout le tour est rendu autrement), ce rappel fait recalculer toute la + boucle d'outils du tour. Avec `on`, le rappel part au bout du dernier résultat + d'outil, et seul ce bout est à calculer — sur le moteur local, et seulement + si la sonde de gabarit a conclu que le rendu de ce modèle bouge (« inconnu » : + message à part, comme sans la clé). Le texte du rappel ne change pas, sa + place si : un modèle entraîné à se méfier des consignes lues dans une sortie + d'outil peut moins bien le suivre. À comparer (tours qui concluent après le + rappel) avant de l'adopter. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index 4638dc4..b02cf98 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -180,7 +180,9 @@ func msgTokens(m Message) int { for _, tc := range m.ToolCalls { n += (len(tc.Function.Name) + len(tc.Function.Arguments)) / 4 } - return n + // Image gardée (KEEP_TURN_IMAGES) : son coût mesuré par le moteur, que le + // seul marqueur textuel sous-estimerait de loin. 0 sans la clé. + return n + m.imgTokens } // estimateTokens estime la taille de l'historique en tokens, raisonnement @@ -376,7 +378,25 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, // vue MODÈLE (msgs) dans tous les cas ; la vue d'envoi ne sert qu'à construire // la requête de résumé. refused : un résumé a été obtenu, mais la réduction // reste sous les 20 % exigés. +// +// Images gardées par KEEP_TURN_IMAGES (chat_keep_images.go) : retirées +// D'ABORD, dans tout l'historique — leur légende et imageLostMarker restent. +// Leur retrait suffit à changer l'historique, même si le résumé qui suit est +// refusé. Sans la clé, il n'y en a aucune : chemin d'avant. func compactMessagesOpt(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) (out []Message, changed, refused bool) { + stripped, freed := dropKeptImages(msgs) + if freed == 0 { + return compactMessagesCore(ctx, msgs, caps, opt) + } + logKeptImagesDropped("compaction", freed) + if out, changed, _ = compactMessagesCore(ctx, stripped, caps, opt); changed { + return out, true, false + } + return stripped, true, false +} + +// compactMessagesCore : la compaction elle-même (voir compactMessagesOpt). +func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) (out []Message, changed, refused bool) { // Budget de queue = fraction de la CONVERSATION (pas de la fenêtre). Le lier à // la fenêtre était le bug : une conversation de 25k tokens dans une fenêtre de // 64k gardait 16k (0.25×64k) en queue → torse minuscule → réduction < 20% → @@ -1027,6 +1047,12 @@ func shrinkToFit(msgs []Message, actual int) ([]Message, bool) { } target := int(float64(ctxWindow()) * 0.6 / ratio) out := append([]Message(nil), msgs...) + // Avant tout : les images gardées par KEEP_TURN_IMAGES, retirées en bloc + // comme à la compaction (sans la clé, aucune). + if s, freed := dropKeptImages(out); freed > 0 { + logKeptImagesDropped("réduction", freed) + out = s + } for i := range out { if out[i].ReasoningContent == "" && out[i].ReasoningModel == "" { continue diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 987f691..7b87a8a 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -797,6 +797,10 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // d'affichage garde le fil complet. Le résumé est un appel modèle non streamé : // il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la // bannière de progression émise par compactAndPublish. + // KEEP_TURN_IMAGES (chat_keep_images.go) : images gardées retirées d'abord + // si le contexte le demande ou si elles ne doivent plus partir. Sans la + // clé, il n'y en a aucune : rien ne change. + msgs, ctxUsed = c.keptImagesTurnStart(epoch, msgs, ctxUsed, peak) // Estimation de début de tour, comparée au premier compte réel du moteur // (journal [ctx], seulement au-delà de 2 % d'écart). startEst := ctxUsed diff --git a/internal/loki/chat_keep_images.go b/internal/loki/chat_keep_images.go new file mode 100644 index 0000000..9511a17 --- /dev/null +++ b/internal/loki/chat_keep_images.go @@ -0,0 +1,426 @@ +package loki + +// chat_keep_images.go — KEEP_TURN_IMAGES : garder dans l'historique les images +// que montrent les outils (web_screenshot, browser_screenshot, see_image), au +// lieu de les jeter à la fin du tour. Opt-in, off par défaut, ZONE GRISE. +// +// Sans la clé, ces images sont ÉPHÉMÈRES (voir runChat) : le modèle les voit +// pendant le tour, puis elles disparaissent de l'historique. Le tour suivant +// renvoie donc un fil où elles manquent : le préfixe que le moteur a en cache +// diverge à la première, et toute la boucle d'outils qui suivait — souvent des +// dizaines d'étapes de navigation — est recalculée. +// +// Avec la clé, une image est gardée telle qu'envoyée, sous conditions : +// +// - vision réellement active (visionEnabled et engineSeesImages, revérifiés à +// chaque tour : un preset sans vision n'en reçoit jamais) ; un preset +// externe n'est concerné que si sa vision est déclarée, et chaque image +// gardée y est alors refacturée à chaque tour ; +// - rangée par référence (chat_images.go, chiffrée si la mémoire l'est) et +// relue à l'octet près avant d'être gardée : le modèle reçoit les mêmes +// octets à chaque envoi ; +// - coût MESURÉ par le moteur (écart de usage.prompt_tokens entre deux +// requêtes, moins l'estimation du texte ajouté) et non estimé : une image +// dont le coût ne se mesure pas (moteur sans usage, requête relancée, +// historique réécrit entre-temps) reste éphémère ; +// - toutes les images gardées tiennent dans keepImgPct % de la fenêtre. La +// première qui ne tient pas reste éphémère, et toutes celles qui la suivent +// dans le tour aussi : le préfixe divergera de toute façon à elle ; +// - retirées D'ABORD par toute compaction ou réduction forcée (légende et +// imageLostMarker gardés), avant tout résumé ; un rechargement du fil les +// retire aussi (stripImageParts), comme les pièces jointes. +// +// Ce que ça coûte : plus d'information gardée, donc une compaction (avec +// perte) qui arrive plus tôt — d'où la borne et le retrait prioritaire. + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// keepImgPct : part maximale de la fenêtre que les images gardées occupent. +const keepImgPct = 10 + +// keepImgMinTokens : en dessous, la mesure ne vaut rien (écart noyé dans +// l'erreur d'estimation du texte) — l'image reste éphémère. +const keepImgMinTokens = 16 + +func keepTurnImagesOn(cfg map[string]string) bool { + switch strings.ToLower(strings.TrimSpace(cfg["KEEP_TURN_IMAGES"])) { + case "on", "1", "true", "yes", "oui": + return true + } + return false +} + +func keepImgBudget() int { return ctxWindow() * keepImgPct / 100 } + +// keptImageTokens : coût total des images gardées de ces messages. +func keptImageTokens(msgs []Message) int { + n := 0 + for _, m := range msgs { + n += m.imgTokens + } + return n +} + +func hasKeptImages(msgs []Message) bool { + for _, m := range msgs { + if m.imgTokens > 0 { + return true + } + } + return false +} + +// dropKeptImages renvoie une COPIE des messages où chaque image gardée devient +// sa légende suivie d'imageLostMarker (la forme de stripImageParts), et le coût +// libéré. Rien de gardé : msgs tel quel, 0. Le message reste un relais +// (ImgRelay) : ni une demande, ni la preuve qu'une demande a été servie. +func dropKeptImages(msgs []Message) ([]Message, int) { + var out []Message + freed := 0 + for i, m := range msgs { + if m.imgTokens <= 0 { + continue + } + if out == nil { + out = append([]Message(nil), msgs...) + } + var texts []string + for _, p := range contentParts(m.Content) { + if t, ok := p["text"].(string); ok && p["type"] == "text" { + texts = append(texts, t) + } + } + freed += m.imgTokens + m.Content = strings.Join(texts, "\n") + imageLostMarker + m.imgTokens = 0 + out[i] = m + } + if out == nil { + return msgs, 0 + } + return out, freed +} + +func logKeptImagesDropped(phase string, freed int) { + fmt.Fprintf(os.Stderr, "[images] %s : images gardées retirées (%d jetons)\n", phase, freed) +} + +// stripRelayTags retire la marque ImgRelay avant l'envoi : propre à Loki, elle +// n'a rien à faire dans la requête. Aucune marque : msgs tel quel, sans copie. +func stripRelayTags(msgs []Message) []Message { + var out []Message + for i, m := range msgs { + if !m.ImgRelay { + continue + } + if out == nil { + out = append([]Message(nil), msgs...) + } + m.ImgRelay = false + out[i] = m + } + if out == nil { + return msgs + } + return out +} + +// refRelayImage range l'image d'un relais par référence et renvoie le message +// qui la porte ainsi, avec le poids de l'image (octets, pour répartir une +// mesure entre plusieurs images d'une même étape). ok=false — format sans +// extension connue, disque plein, mémoire chiffrée verrouillée, relecture qui +// ne redonne pas exactement la même data-URL — : l'image reste éphémère. +func refRelayImage(m Message) (Message, int, bool) { + parts := contentParts(m.Content) + np := make([]map[string]any, len(parts)) + weight := 0 + for i, p := range parts { + np[i] = p + _, u := partImageURL(p) + if u == "" { + continue + } + if weight > 0 || !strings.HasPrefix(u, "data:image/") { + return m, 0, false + } + k := strings.Index(u, ";base64,") + if k < 0 { + return m, 0, false + } + mime := u[5:k] + if imgExtByMime[mime] == "" { + return m, 0, false + } + b, err := base64.StdEncoding.DecodeString(u[k+8:]) + if err != nil || len(b) == 0 { + return m, 0, false + } + ref, err := storeChatImage(b, mime) + if err != nil { + return m, 0, false + } + // Gardée en vie : storeChatImage ne réécrit pas un fichier déjà là (mêmes + // octets), et pruneChatImages efface au bout de chatImgKeep d'après la + // date du fichier — une image revue aujourd'hui ne doit pas tomber demain + // en « [image indisponible] » au milieu d'un fil. + now := time.Now() + _ = os.Chtimes(filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme)), now, now) + if back, ok := chatImageDataURL(ref); !ok || back != u { + return m, 0, false + } + np[i] = map[string]any{"type": "image_url", "image_url": map[string]any{"url": ref}} + weight = len(b) + } + if weight == 0 { + return m, 0, false + } + m.Content = np + return m, weight, true +} + +// keepImages : l'état de KEEP_TURN_IMAGES pendant UN tour (runChat). nil = clé +// absente, tâche, sous-agent, vérification ou vision inactive : toutes ses +// méthodes rendent alors le comportement d'avant. +type keepImages struct { + // stopped : une image de ce tour n'a pas été gardée ; les suivantes ne le + // sont pas non plus (le préfixe du tour suivant diverge à elle). + stopped bool + // Images ajoutées à l'étape courante, gardées à l'essai jusqu'à la mesure + // de la requête qui les montre la première. + pend []keepPend + // pendReq : numéro de cette requête. pendBase : contexte que la complétion + // précédente laisse (ctxAfter). pendFrom : premier message ajouté depuis. + pendReq, pendBase, pendFrom int + // reqN : requêtes envoyées ; sentLen : messages de la dernière. + reqN, sentLen int + // lastAfter : ctxAfter de la dernière complétion complète (0 = inconnu). + lastAfter int +} + +type keepPend struct{ msgIdx, extraIdx, weight int } + +// newKeepImages : l'état du tour, ou nil. Fil principal d'une discussion +// seulement : les tâches, sous-agents et la vérification ne persistent pas +// leur historique comme elle. +func newKeepImages(cfg map[string]string, tag perfTag) *keepImages { + if !keepTurnImagesOn(cfg) || tag.kind != perfMain || tag.conv == "" { + return nil + } + if !visionEnabled() || !engineSeesImages() { + return nil + } + return &keepImages{} +} + +// relay ajoute le message qui relaie une image d'outil. Sans état : à la vue +// du modèle seulement, comme avant. Avec : gardé à l'essai dans extra aussi, +// par référence, si le budget n'est pas déjà plein. from = début des messages +// de l'étape (premier résultat d'outil). +func (k *keepImages) relay(messages, extra []Message, m Message, from int) ([]Message, []Message) { + if k == nil { + return append(messages, m), extra + } + m.ImgRelay = true + if !k.stopped { + if r, w, ok := refRelayImage(m); ok && keptImageTokens(messages) < keepImgBudget() { + if len(k.pend) == 0 { + k.pendReq, k.pendBase, k.pendFrom = k.reqN+1, k.lastAfter, from + } + messages = append(messages, r) + extra = append(extra, r) + k.pend = append(k.pend, keepPend{msgIdx: len(messages) - 1, extraIdx: len(extra) - 1, weight: w}) + return messages, extra + } + k.stopped = true + } + return append(messages, m), extra +} + +// sending : une requête part avec n messages. +func (k *keepImages) sending(n int) { + if k == nil { + return + } + k.reqN++ + k.sentLen = n +} + +// observe : une complétion est lue. Mesure le coût des images à l'essai si +// c'est la requête qui les a montrées la première, et les garde si le budget +// le permet ; sinon elles redeviennent éphémères (retirées d'extra). Renvoie +// extra. +func (k *keepImages) observe(complete bool, st StatsEvent, messages, extra []Message) []Message { + if k == nil { + return extra + } + after := 0 + if complete && st.PromptTokensTotal > 0 { + after = st.ctxAfter() + } + if len(k.pend) > 0 { + if !k.measure(after, st.PromptTokensTotal, messages, extra) { + extra = k.reject(extra) + } + k.pend = nil + } + k.lastAfter = after + return extra +} + +// measure répartit le coût mesuré entre les images à l'essai et les garde si +// tout tient dans le budget. false = mesure impossible ou budget dépassé. +func (k *keepImages) measure(after, prompt int, messages, extra []Message) bool { + if after <= 0 || k.pendBase <= 0 || k.pendReq != k.reqN || + k.pendFrom > k.sentLen || k.sentLen > len(messages) { + return false + } + // Ce que la requête a ajouté depuis la précédente, hors images : résultats + // d'outils, légendes exceptées, ajouts en cours de réponse, rappels. + text := 0 + for _, m := range messages[k.pendFrom:k.sentLen] { + if !m.ImgRelay { + text += msgTokens(m) + } + } + img := prompt - k.pendBase - text + weight := 0 + for _, p := range k.pend { + weight += p.weight + } + if weight <= 0 || img < keepImgMinTokens*len(k.pend) { + return false + } + if keptImageTokens(messages)+img > keepImgBudget() { + return false + } + shares := make([]int, len(k.pend)) + left := img + for i, p := range k.pend { + shares[i] = img * p.weight / weight + if i == len(k.pend)-1 { + shares[i] = left + } + if shares[i] < keepImgMinTokens { + return false + } + left -= shares[i] + } + for _, p := range k.pend { + if p.msgIdx >= len(messages) || p.extraIdx >= len(extra) || !messages[p.msgIdx].ImgRelay || !extra[p.extraIdx].ImgRelay { + return false + } + } + for i, p := range k.pend { + messages[p.msgIdx].imgTokens = shares[i] + extra[p.extraIdx].imgTokens = shares[i] + } + return true +} + +// reject retire d'extra les images à l'essai : éphémères, comme sans la clé. +func (k *keepImages) reject(extra []Message) []Message { + if len(k.pend) == 0 { + return extra + } + k.stopped = true + drop := map[int]bool{} + for _, p := range k.pend { + drop[p.extraIdx] = true + } + out := make([]Message, 0, len(extra)) + for i, m := range extra { + if !drop[i] { + out = append(out, m) + } + } + k.pend = nil + return out +} + +// rewritten : l'historique vient d'être réécrit en cours de tour (compaction, +// réduction) et extra repart de zéro. Les images à l'essai ne se mesurent plus +// (ni base ni position) : éphémères, et le tour s'arrête là pour les suivantes. +func (k *keepImages) rewritten() { + if k == nil { + return + } + if len(k.pend) > 0 { + k.stopped = true + } + k.pend = nil +} + +// publishable : l'historique publié par une réécriture en cours de tour, sans +// les relais dont l'image n'est pas gardée (éphémères, ou à l'essai). Sans +// état, tel quel — comportement d'avant. +func (k *keepImages) publishable(msgs []Message) []Message { + if k == nil { + return msgs + } + out := msgs[:0:0] + for _, m := range msgs { + if m.ImgRelay && m.imgTokens == 0 && hasImagePart(m) { + continue + } + out = append(out, m) + } + return out +} + +// finish : fin du tour. Une image encore à l'essai (tour arrêté, erreur, fin +// juste après elle) n'a pas été mesurée : éphémère. +func (k *keepImages) finish(extra []Message) []Message { + if k == nil { + return extra + } + return k.reject(extra) +} + +func hasImagePart(m Message) bool { + for _, p := range contentParts(m.Content) { + if p["type"] == "image_url" { + return true + } + } + return false +} + +// keptImagesTurnStart : début d'un tour de discussion. Les images gardées +// sont retirées d'abord — avant toute compaction, qui ne résume alors que si +// c'est encore nécessaire — quand le contexte la réclame, ou quand elles ne +// doivent plus partir : clé retirée, vision absente (un moteur sans projecteur +// refuserait la requête). Renvoie l'historique et le contexte à jour. +func (c *Conversation) keptImagesTurnStart(epoch int, msgs []Message, ctxUsed, peak int) ([]Message, int) { + if !hasKeptImages(msgs) { + return msgs, ctxUsed + } + if keepTurnImagesOn(ReadConfig()) && visionEnabled() && engineSeesImages() && !compactNeeded(msgs, ctxUsed, peak) { + return msgs, ctxUsed + } + out, freed := dropKeptImages(msgs) + logKeptImagesDropped("début-tour", freed) + if ctxUsed > 0 { + ctxUsed = max(ctxUsed-freed, 1) + } + c.mu.Lock() + cur := 0 + if c.epoch == epoch { + c.Messages = append([]Message(nil), out...) + if c.CtxUsed > 0 { + c.CtxUsed = max(c.CtxUsed-freed, 1) + } + cur = c.CtxUsed + } + c.mu.Unlock() + if cur > 0 { + c.appendDelta(epoch, map[string]any{"ctx_used": cur}) + } + return out, ctxUsed +} diff --git a/internal/loki/chat_keep_images_test.go b/internal/loki/chat_keep_images_test.go new file mode 100644 index 0000000..8a203f5 --- /dev/null +++ b/internal/loki/chat_keep_images_test.go @@ -0,0 +1,528 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +// sseSeeImage : le modèle demande see_image sur img.png ; usage du prompt +// prompt (complétion de 20 jetons). prompt = 0 : aucun usage, comme un moteur +// qui ne le renvoie pas. +func sseSeeImage(prompt int) string { + s := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"v1","type":"function","function":{"name":"see_image","arguments":"{\"file\":\"img.png\"}"}}]}}]}` + "\n\n" + + `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n" + if prompt > 0 { + s += fmt.Sprintf(`data: {"choices":[],"usage":{"prompt_tokens":%d,"completion_tokens":20}}`, prompt) + "\n\n" + } + return s + "data: [DONE]\n\n" +} + +func sseAnswer(text string, prompt int) string { + s := sseChunk(text) + `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n" + if prompt > 0 { + s += fmt.Sprintf(`data: {"choices":[],"usage":{"prompt_tokens":%d,"completion_tokens":5}}`, prompt) + "\n\n" + } + return s + "data: [DONE]\n\n" +} + +// visionServer : un llama-server de test qui déclare la vision (/props) et +// répond aux complétions dans l'ordre des steps, en gardant chaque corps. +func visionServer(t *testing.T, steps ...string) func() []map[string]any { + t.Helper() + var mu sync.Mutex + var bodies []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/props" { + _, _ = w.Write([]byte(`{"modalities":{"vision":true}}`)) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + bodies = append(bodies, body) + n := len(bodies) - 1 + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write([]byte(steps[min(n, len(steps)-1)])) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + for k, v := range map[string]string{"PORT": u.Port(), "MMPROJ": "/models/mmproj.gguf"} { + if err := SetConfigKey(k, v); err != nil { + t.Fatal(err) + } + } + // Sonde de vision en cache 10 s : un autre test a pu conclure « non ». + visionProbeMu.Lock() + visionProbeAt = time.Time{} + visionProbeMu.Unlock() + return func() []map[string]any { + mu.Lock() + defer mu.Unlock() + return append([]map[string]any(nil), bodies...) + } +} + +// writeTestPNG pose img.png (petit, sous maxImageDim : octets inchangés) dans +// le dossier de travail et renvoie la data-URL que le modèle doit recevoir. +func writeTestPNG(t *testing.T, dir string) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for i := 0; i < 8; i++ { + img.Set(i, i, color.RGBA{R: 200, A: 255}) + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "img.png"), buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + part := imageURLPart(buf.Bytes(), "image/png") + return part["image_url"].(map[string]any)["url"].(string) +} + +func mainCtx(t *testing.T) context.Context { + return withPerf(t.Context(), perfMain, "conv-test") +} + +// relayOf : le message de relais d'une requête (celui qui porte l'image). +func relayOf(t *testing.T, body map[string]any) map[string]any { + t.Helper() + for _, m := range body["messages"].([]any) { + mm := m.(map[string]any) + if parts, ok := mm["content"].([]any); ok && len(parts) == 2 { + return mm + } + } + t.Fatal("aucun relais d'image dans la requête") + return nil +} + +// Sans la clé : image éphémère, relais en base64, aucune marque — et, avec la +// clé, la requête qui montre l'image est la MÊME à l'octet près. +func TestKeepImagesSansCleRequeteIdentique(t *testing.T) { + dir := withWorkspace(t) + want := writeTestPNG(t, dir) + + reqs := visionServer(t, sseSeeImage(1000), sseAnswer("vu", 1331)) + extra, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + if err != nil { + t.Fatal(err) + } + off := reqs() + if len(off) != 2 { + t.Fatalf("%d requêtes, attendu 2", len(off)) + } + relay := relayOf(t, off[1]) + if _, tagged := relay["img_relay"]; tagged { + t.Fatal("marque img_relay envoyée sans la clé") + } + part := relay["content"].([]any)[1].(map[string]any) + if got := part["image_url"].(map[string]any)["url"]; got != want { + t.Fatal("image du relais différente de celle du fichier") + } + if len(extra) != 2 { + t.Fatalf("sans la clé, l'image doit rester éphémère : %+v", extra) + } + for _, m := range extra { + if m.ImgRelay || m.imgTokens != 0 { + t.Fatalf("message marqué sans la clé : %+v", m) + } + } + + if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil { + t.Fatal(err) + } + reqs = visionServer(t, sseSeeImage(1000), sseAnswer("vu", 1331)) + if _, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + on := reqs() + if len(on) != 2 { + t.Fatalf("%d requêtes avec la clé, attendu 2", len(on)) + } + for i := range on { + if !reflect.DeepEqual(on[i], off[i]) { + a, _ := json.Marshal(off[i]) + b, _ := json.Marshal(on[i]) + t.Fatalf("requête %d différente avec la clé :\n%s\n%s", i, a, b) + } + } +} + +// Avec la clé : l'image est gardée par référence, son coût est celui que le +// moteur a mesuré (écart de prompt moins le texte ajouté), et le tour suivant +// renvoie exactement les mêmes octets. +func TestKeepImagesGardeeEtMesuree(t *testing.T) { + dir := withWorkspace(t) + want := writeTestPNG(t, dir) + if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil { + t.Fatal(err) + } + toolMsg := Message{Role: "tool", ToolCallID: "v1", Content: "[ok] image chargée : img.png"} + // ctxAfter de la 1re complétion = 1000 + 20 ; + résultat d'outil ; + 300 + // pour l'image. + prompt2 := 1020 + msgTokens(toolMsg) + 300 + visionServer(t, sseSeeImage(1000), sseAnswer("vu", prompt2)) + extra, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + if err != nil { + t.Fatal(err) + } + if len(extra) != 3 { + t.Fatalf("image non gardée : %+v", extra) + } + r := extra[2] + if !r.ImgRelay || r.imgTokens != 300 { + t.Fatalf("relais gardé = %+v, attendu marqué et 300 jetons", r) + } + _, u := partImageURL(contentParts(r.Content)[1]) + if !strings.HasPrefix(u, imgRefScheme) { + t.Fatalf("image gardée en base64 et non par référence : %.60s", u) + } + if !isLokiInjected(r) { + t.Fatal("relais pris pour une demande de l'utilisateur") + } + + // Tour suivant : l'historique renvoyé repart avec la même image, sans la + // marque, à l'octet près de ce que le moteur a déjà vu. + hist := append([]Message{um("regarde img.png")}, extra...) + hist = append(hist, am("vu"), um("et maintenant ?")) + reqs := visionServer(t, sseAnswer("ok", 0)) + if _, err := runChat(mainCtx(t), hist, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + next := reqs()[0] + relay := relayOf(t, next) + if _, tagged := relay["img_relay"]; tagged { + t.Fatal("marque img_relay envoyée au moteur") + } + if got := relay["content"].([]any)[1].(map[string]any)["image_url"].(map[string]any)["url"]; got != want { + t.Fatal("le tour suivant ne renvoie pas les mêmes octets d'image") + } +} + +// Garde : au-delà de 10 % de la fenêtre, l'image reste éphémère. +func TestKeepImagesBudget(t *testing.T) { + dir := withWorkspace(t) + writeTestPNG(t, dir) + for k, v := range map[string]string{"KEEP_TURN_IMAGES": "on", "CTX": "2000"} { + if err := SetConfigKey(k, v); err != nil { + t.Fatal(err) + } + } + toolMsg := Message{Role: "tool", ToolCallID: "v1", Content: "[ok] image chargée : img.png"} + visionServer(t, sseSeeImage(1000), sseAnswer("vu", 1020+msgTokens(toolMsg)+300)) + extra, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + if err != nil { + t.Fatal(err) + } + if len(extra) != 2 { + t.Fatalf("image de 300 jetons gardée malgré un budget de 200 : %+v", extra) + } +} + +// Moteur sans usage : rien à mesurer, l'image reste éphémère. +func TestKeepImagesSansMesure(t *testing.T) { + dir := withWorkspace(t) + writeTestPNG(t, dir) + if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil { + t.Fatal(err) + } + visionServer(t, sseSeeImage(0), sseAnswer("vu", 0)) + extra, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + if err != nil { + t.Fatal(err) + } + if len(extra) != 2 { + t.Fatalf("image gardée sans mesure : %+v", extra) + } +} + +// Ni tâche ni sous-agent : seul le fil d'une discussion garde ses images. +func TestKeepImagesFilPrincipalSeulement(t *testing.T) { + testHome(t) + if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil { + t.Fatal(err) + } + if newKeepImages(ReadConfig(), perfTag{kind: perfTask, conv: "task:x"}) != nil || + newKeepImages(ReadConfig(), perfTag{kind: perfMain}) != nil { + t.Fatal("état de KEEP_TURN_IMAGES hors d'une discussion") + } + var k *keepImages // clé absente : chaque méthode rend le comportement d'avant + m := []Message{um("a")} + got, e := k.relay(m, nil, um("img"), 1) + if len(got) != 2 || e != nil || got[1].ImgRelay { + t.Fatal("relais sans état modifié") + } + k.sending(2) + k.rewritten() + if k.observe(true, StatsEvent{}, got, nil) != nil || k.finish(nil) != nil || + len(k.publishable(got)) != 2 { + t.Fatal("état nil qui agit") + } +} + +func keptRelay(tokens int) Message { + return Message{Role: "user", ImgRelay: true, imgTokens: tokens, Content: []map[string]any{ + {"type": "text", "text": "Voici la capture demandée."}, + {"type": "image_url", "image_url": map[string]any{"url": imgRefScheme + "abc.png"}}, + }} +} + +// Retrait : légende + imageLostMarker, toujours un relais, coût libéré ; rien +// à faire sans image gardée. +func TestDropKeptImages(t *testing.T) { + plain := []Message{um("q"), tm("r")} + if out, freed := dropKeptImages(plain); freed != 0 || &out[0] != &plain[0] { + t.Fatal("copie ou retrait sans image gardée") + } + msgs := []Message{um("q"), atc("see_image"), tm("[ok]"), keptRelay(500), am("vu")} + out, freed := dropKeptImages(msgs) + if freed != 500 { + t.Fatalf("libéré %d, attendu 500", freed) + } + r := out[3] + if r.Content != "Voici la capture demandée."+imageLostMarker || !r.ImgRelay || r.imgTokens != 0 { + t.Fatalf("relais retiré = %+v", r) + } + if msgs[3].imgTokens != 500 { + t.Fatal("l'historique d'origine a été modifié en place") + } + if estimateTokens(msgs)-estimateTokens(out) < 400 { + t.Fatal("le coût mesuré de l'image n'est pas compté dans l'estimation") + } +} + +// Toute compaction retire les images gardées d'abord, et ce retrait compte +// comme un changement même quand rien n'est à résumer. +func TestCompactionRetireLesImagesDAbord(t *testing.T) { + testHome(t) + msgs := []Message{{Role: "system", Content: "sys"}, um("q"), atc("see_image"), tm("[ok]"), keptRelay(5000), am("vu")} + out, changed, _ := compactMessagesOpt(t.Context(), msgs, Caps{}, compactOpts{}) + if !changed || hasKeptImages(out) { + t.Fatalf("images gardées non retirées par la compaction : changé=%v", changed) + } + out, changed = shrinkToFit(msgs, 0) + if !changed || hasKeptImages(out) { + t.Fatal("images gardées non retirées par la réduction forcée") + } + // Sans image gardée : chemin d'avant, rien de changé sur un torse vide. + plain := []Message{{Role: "system", Content: "sys"}, um("q"), am("r")} + if _, changed, _ := compactMessagesOpt(t.Context(), plain, Caps{}, compactOpts{}); changed { + t.Fatal("compaction d'un fil sans torse") + } +} + +// Un relais n'est ni une demande à réinjecter, ni la preuve qu'une demande a +// été servie ; rouvert, il le reste (marque persistée), et la marque ne part +// jamais au moteur. +func TestRelaisPasPrisPourLaDemande(t *testing.T) { + r := keptRelay(10) + msgs := []Message{um("ancienne"), am("ok"), um("vraie demande"), atc("see_image"), tm("[ok]"), r} + if p := compactPending(msgs, msgs[:3], 3); len(p) != 1 || msgText(p[0]) != "vraie demande" { + t.Fatalf("demande réinjectée : %+v", p) + } + raw, _ := json.Marshal(r) + var back Message + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + back = stripImageParts([]Message{back})[0] + if !isLokiInjected(back) { + t.Fatal("relais rouvert pris pour une demande") + } + sent, _ := wireMessages(msgs, false, echoPolicy{}) + for _, m := range sent { + if m.ImgRelay { + t.Fatal("marque de relais dans la requête") + } + } + if !msgs[5].ImgRelay { + t.Fatal("l'historique a perdu sa marque à l'envoi") + } +} + +// Réécriture en cours de tour : une image non gardée n'entre pas dans +// l'historique publié ; une image gardée, ou déjà retirée, si. +func TestKeepImagesPublication(t *testing.T) { + k := &keepImages{} + eph := Message{Role: "user", ImgRelay: true, Content: []map[string]any{ + {"type": "text", "text": "Image :"}, {"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AA=="}}}} + stub := Message{Role: "user", ImgRelay: true, Content: "Image :" + imageLostMarker} + msgs := []Message{um("q"), tm("a"), eph, tm("b"), keptRelay(50), stub} + got := k.publishable(msgs) + if len(got) != 5 || got[2].Content != "b" { + t.Fatalf("publié : %+v", got) + } + k.pend = []keepPend{{msgIdx: 2, extraIdx: 0, weight: 1}} + k.rewritten() + if !k.stopped || k.pend != nil { + t.Fatal("images à l'essai encore suivies après une réécriture") + } +} + +// Début de tour : clé retirée → images gardées retirées ; clé présente et +// vision active sans besoin de compacter → gardées. +func TestKeptImagesDebutDeTour(t *testing.T) { + withWorkspace(t) + visionServer(t, sseStop) + c := newTestConv() + msgs := []Message{um("q"), atc("see_image"), tm("[ok]"), keptRelay(300), am("vu"), um("suite")} + c.Messages = append([]Message(nil), msgs...) + c.CtxUsed = 2000 + out, used := c.keptImagesTurnStart(c.epoch, msgs, 2000, 0) + if hasKeptImages(out) || used != 1700 || hasKeptImages(c.Messages) || c.CtxUsed != 1700 { + t.Fatalf("clé absente : images gardées encore là (used=%d)", used) + } + if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil { + t.Fatal(err) + } + c.Messages = append([]Message(nil), msgs...) + out, used = c.keptImagesTurnStart(c.epoch, msgs, 2000, 0) + if !hasKeptImages(out) || used != 2000 { + t.Fatal("images retirées alors que rien ne le demandait") + } + // Contexte au seuil : retirées d'abord. + out, used = c.keptImagesTurnStart(c.epoch, msgs, ctxWindow(), 0) + if hasKeptImages(out) || used != ctxWindow()-300 { + t.Fatal("images gardées malgré un contexte au seuil") + } +} + +// NUDGE_IN_TOOL : seulement avec la clé, sur le moteur local, si la sonde a dit +// « instable » pour CE modèle. +func TestNudgeInToolOn(t *testing.T) { + freshTplProbe(t) + cfg := map[string]string{"MODEL": "/models/qwen35.gguf"} + set := func(r tplProbeResult) { + tplProbeMu.Lock() + tplProbeLast = &r + tplProbeMu.Unlock() + } + set(tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}) + if nudgeInToolOn(cfg, chatEndpoint{}) { + t.Fatal("actif sans la clé") + } + cfg["NUDGE_IN_TOOL"] = "on" + if !nudgeInToolOn(cfg, chatEndpoint{}) { + t.Fatal("inactif avec la clé sur un gabarit instable") + } + if nudgeInToolOn(cfg, chatEndpoint{External: true}) { + t.Fatal("actif sur un preset externe") + } + for _, r := range []tplProbeResult{ + {Model: "qwen35.gguf", PrefixStable: tplUnknown}, + {Model: "qwen35.gguf", PrefixStable: tplYes}, + {Model: "autre.gguf", PrefixStable: tplNo}, + } { + set(r) + if nudgeInToolOn(cfg, chatEndpoint{}) { + t.Fatalf("actif pour %+v", r) + } + } +} + +func TestNudgeIntoTool(t *testing.T) { + msgs := []Message{um("q"), atc("glob"), tm("res")} + extra := []Message{atc("glob"), tm("res")} + m2, e2, ok := nudgeIntoTool(msgs, extra, "[system] stop") + if !ok || m2[2].Content != "res\n\n[system] stop" || !reflect.DeepEqual(m2[2], e2[1]) { + t.Fatalf("rappel mal posé : %+v / %+v", m2, e2) + } + if msgs[2].Content != "res" || extra[1].Content != "res" { + t.Fatal("messages modifiés en place") + } + // Dernier message qui n'est pas un résultat d'outil, ou historique réécrit. + if _, _, ok := nudgeIntoTool(append(msgs, um("img")), extra, "x"); ok { + t.Fatal("rappel posé derrière un message user") + } + if _, _, ok := nudgeIntoTool(msgs, nil, "x"); ok { + t.Fatal("rappel posé sans le résultat dans l'historique") + } +} + +// Bout à bout : avec la clé et un gabarit instable, le rappel part au bout du +// résultat d'outil, persisté tel qu'envoyé, sans message user à part. +func TestRappelDeBudgetDansLeResultat(t *testing.T) { + withWorkspace(t) + freshTplProbe(t) + for k, v := range map[string]string{"AGENT_BUDGET": "1", "NUDGE_IN_TOOL": "on", "MODEL": "/models/qwen35.gguf"} { + if err := SetConfigKey(k, v); err != nil { + t.Fatal(err) + } + } + tplProbeMu.Lock() + tplProbeLast = &tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo} + tplProbeMu.Unlock() + reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop) + extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + if err != nil { + t.Fatal(err) + } + sent := reqMessages(t, reqs()[1]) + last := sent[len(sent)-1] + if last.Role != "tool" || !strings.HasSuffix(msgText(last), "unless exactly one specific call is genuinely still missing.") { + t.Fatalf("rappel absent du résultat d'outil : %+v", last) + } + if len(extra) != 2 || !reflect.DeepEqual(extra[1], last) { + t.Fatalf("historique %+v : le résultat doit y être tel qu'envoyé", extra) + } +} + +// En cours de tour, au seuil : les images gardées partent d'abord, et le +// résumé n'est pas demandé si leur retrait suffit. +func TestKeepImagesRetireesEnCoursDeTour(t *testing.T) { + withWorkspace(t) + for k, v := range map[string]string{"KEEP_TURN_IMAGES": "on", "CTX": "2000"} { + if err := SetConfigKey(k, v); err != nil { + t.Fatal(err) + } + } + glob := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"g1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n" + + `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n" + + `data: {"choices":[],"usage":{"prompt_tokens":1600,"completion_tokens":10}}` + "\n\ndata: [DONE]\n\n" + reqs := visionServer(t, glob, sseAnswer("fini", 0)) + hist := []Message{um("q"), atc("see_image"), tm("[ok]"), keptRelay(1000), am("vu"), um("suite")} + var published []Message + extra, err := runChat(mainCtx(t), hist, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool { + if ev.NewHistory != nil { + published = ev.NewHistory + } + return true + }) + if err != nil { + t.Fatal(err) + } + if n := len(reqs()); n != 2 { + t.Fatalf("%d requêtes : un résumé est parti alors que retirer l'image suffisait", n) + } + if published == nil || hasKeptImages(published) || len(published) != len(hist)+2 { + t.Fatalf("historique publié : %+v", published) + } + if published[3].Content != "Voici la capture demandée."+imageLostMarker { + t.Fatalf("relais publié : %+v", published[3]) + } + if relay := reqMessages(t, reqs()[1])[3]; msgText(relay) != "Voici la capture demandée."+imageLostMarker { + t.Fatalf("image encore envoyée après son retrait : %+v", relay) + } + if len(extra) != 0 { + t.Fatalf("extra après publication : %+v", extra) + } +} diff --git a/internal/loki/chat_projsnap.go b/internal/loki/chat_projsnap.go index a68b3db..694fab8 100644 --- a/internal/loki/chat_projsnap.go +++ b/internal/loki/chat_projsnap.go @@ -235,6 +235,7 @@ func projSnapKey(caps Caps, cfg map[string]string) string { var projSnapIgnoredKeys = map[string]bool{ "PROJ_SNAPSHOT": true, "PREWARM": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true, "COMPACT": true, "COMPACT_CONTINUATION": true, "CRAWL4AI_URL": true, "CRAWL4AI_KEY": true, "WEB_ENGINE": true, + "KEEP_TURN_IMAGES": true, "NUDGE_IN_TOOL": true, } // projSnapSysHash : empreinte du bloc système commun et des outils. S'ils diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 91c0bf8..c0deb24 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -32,6 +32,17 @@ type Message struct { // raisonnement quand il ne doit pas partir. Vides : JSON inchangé. ReasoningContent string `json:"reasoning_content,omitempty"` ReasoningModel string `json:"reasoning_model,omitempty"` + // ImgRelay : message `user` qui relaie l'image d'un outil (capture, + // see_image) sous KEEP_TURN_IMAGES (chat_keep_images.go) — jamais posé sans + // la clé. Persisté pour qu'un fil rouvert le reconnaisse encore comme un + // relais et non comme une demande (isLokiInjected) ; retiré à l'envoi + // (wireMessages). Faux : JSON inchangé. + ImgRelay bool `json:"img_relay,omitempty"` + // imgTokens : coût mesuré par le moteur de l'image d'un relais GARDÉ dans + // l'historique (KEEP_TURN_IMAGES) ; 0 = image non gardée, ou déjà retirée. + // En mémoire seulement : un fil rechargé a de toute façon perdu ses images + // (stripImageParts). + imgTokens int } type ToolCall struct { @@ -1152,6 +1163,9 @@ func turnEchoPolicy(off bool) echoPolicy { // renvoyé selon pol — et le nombre de raisonnements renvoyés. func wireMessages(messages []Message, toolChoiceNone bool, pol echoPolicy) ([]Message, int) { sent := normalizeSystemMessages(messages) + // Marque de relais d'image (KEEP_TURN_IMAGES) : propre à Loki, jamais au + // moteur. Sans la clé, aucune : la tranche repart telle quelle. + sent = stripRelayTags(sent) if toolChoiceNone { sent = withTrailingHint(sent, toolsOffHint) } @@ -1232,6 +1246,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // runChatTools est runChat avec les outils déjà calculés par prepareTurn : ceux // que décrit le préambule, à l'octet près. func runChatTools(ctx context.Context, messages []Message, tools []Tool, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) { + // KEEP_TURN_IMAGES (chat_keep_images.go) : nil sans la clé, et tout se + // passe alors comme avant. En fin de tour, une image gardée à l'essai mais + // jamais mesurée redevient éphémère, quelle que soit la sortie de la boucle. + kt := newKeepImages(ReadConfig(), perfTagOf(ctx)) + extra, err := runChatLoop(ctx, kt, messages, tools, temperature, caps, cb, injectQueued...) + return kt.finish(extra), err +} + +// runChatLoop : la boucle de runChatTools. +func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools []Tool, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) { var extra []Message // Bloc projet figé de la discussion (PROJ_SNAPSHOT, chat_projsnap.go) : nil // hors tour de discussion avec la clé. Masqué pour la suite du contexte — @@ -1243,11 +1267,19 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera // newHistory publie l'historique réécrit en cours de tour (compaction, // réduction). Le début du prompt change de toute façon : avec la clé, le // bloc figé y est remplacé par le vivant et les mises à jour retirées. + // publishHistory : la même publication, sans rafraîchir le bloc projet — + // pour un retrait d'images gardées (KEEP_TURN_IMAGES), qui ne touche pas au + // début du prompt. KEEP_TURN_IMAGES : une image non gardée n'entre pas dans + // l'historique par ce chemin non plus. Sans la clé, publication telle quelle. + publishHistory := func() { + kt.rewritten() + cb(StreamEvent{NewHistory: kt.publishable(append([]Message(nil), messages...))}) + } newHistory := func() { if snapTurn != nil { messages = snapTurn.refresh(messages) } - cb(StreamEvent{NewHistory: append([]Message(nil), messages...)}) + publishHistory() } // Some backends (vanilla llama.cpp builds) don't populate `reasoning_content` // in streaming mode: the model's block (opened by the chat template) @@ -1369,7 +1401,16 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" { budgetNudges++ logBudget(toolRuns, budget, budgetNudges) - messages, extra = appendNudge(messages, extra, msg) + // NUDGE_IN_TOOL (opt-in, gabarits dont le rendu bouge quand un message + // user s'ajoute) : au bout du dernier résultat d'outil. Sinon, ou si + // la forme ne s'y prête pas, un message à part comme toujours. + placed := false + if nudgeInToolOn(chatCfg, ep) { + messages, extra, placed = nudgeIntoTool(messages, extra, msg) + } + if !placed { + messages, extra = appendNudge(messages, extra, msg) + } } // Appels d'outils exécutables pour cette complétion : outils annoncés, // et ni coupés ni neutralisés par tool_choice « none ». @@ -1394,6 +1435,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if err != nil { return extra, err } + kt.sending(len(messages)) // KEEP_TURN_IMAGES : rien sans la clé req.Header.Set("Content-Type", "application/json") // ⚠️ Surtout pas authHeader : celui-ci pose la clé du serveur LOCAL, et // l'envoyer à api.openai.com serait fuiter un secret chez un tiers en même @@ -2046,6 +2088,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera } lastEst = 0 } + // KEEP_TURN_IMAGES : coût des images de l'étape précédente, mesuré sur + // cette complétion ; gardées ou rendues éphémères. Rien sans la clé. + extra = kt.observe(complete, stats, messages, extra) if finishReason == "length" { logCtx("finish=length prompt=%d généré=%d raisonnement=%d fenêtre=%d", stats.PromptTokensTotal, stats.GenTokens, stats.ReasoningTokens, ctxWindow()) } @@ -2462,10 +2507,14 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera // contexte (vu en production : requêtes de 55 000 tokens pour une // fenêtre de 32 768, plus aucun tour ne passait). Le modèle regarde // l'image MAINTENANT et sa description textuelle, elle, reste. + // + // KEEP_TURN_IMAGES (opt-in, chat_keep_images.go) : gardée dans + // l'historique, par référence, sous un budget mesuré — kt.relay. + // Sans la clé, kt est nil et relay fait exactement l'ajout d'avant. if tc.Function.Name == "web_screenshot" { if rel := capturedRelPath(result); rel != "" { if imgMsg, ok := screenshotImageMessage(rel); ok { - messages = append(messages, imgMsg) + messages, extra = kt.relay(messages, extra, imgMsg, stepStart) } } } @@ -2476,7 +2525,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera // dépasser la fenêtre pour de bon. Le modèle regarde l'image // maintenant ; ce qu'il en dit, lui, reste. if visionImg != nil { - messages = append(messages, seeImageMessage(label, visionImg)) + messages, extra = kt.relay(messages, extra, seeImageMessage(label, visionImg), stepStart) } } // Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour : @@ -2501,6 +2550,21 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if ptag.kind == perfMain { mainConv = ptag.conv } + // KEEP_TURN_IMAGES : les images gardées partent AVANT tout résumé ; + // la compaction ne suit que si elle reste nécessaire. Sans la clé, + // aucune image gardée : rien ne change. + if hasKeptImages(messages) && compactNeeded(messages, used, peakGen) { + if s, freed := dropKeptImages(messages); freed > 0 { + logKeptImagesDropped("en-tour", freed) + messages = s + if used > 0 { + used = max(used-freed, 1) + lastEst = used + } + extra = nil + publishHistory() + } + } if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used, messages) { opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol, disableTools || toolChoiceNone) diff --git a/internal/loki/llm_injected.go b/internal/loki/llm_injected.go index d101c72..1866e86 100644 --- a/internal/loki/llm_injected.go +++ b/internal/loki/llm_injected.go @@ -55,6 +55,11 @@ func isLokiInjected(m Message) bool { if m.Role != "user" { return false } + // Relais d'image d'un outil gardé dans l'historique (KEEP_TURN_IMAGES) : + // une image montrée par loki, pas une demande. Jamais posé sans la clé. + if m.ImgRelay { + return true + } s, ok := m.Content.(string) if !ok { return false @@ -156,3 +161,54 @@ func withTrailingHint(msgs []Message, hint string) []Message { // Dernier message d'une autre forme (cas théorique) : un message à part. return append(out, Message{Role: "user", Content: lokiNotePrefix + hint}) } + +// nudgeInToolOn : NUDGE_IN_TOOL (opt-in, off par défaut, ZONE GRISE). Sur un +// gabarit dont le rendu d'un tour d'outil change dès qu'un message `user` +// s'ajoute (Qwen3.5 : le nouveau message devient la « dernière question », et +// tous les messages assistant du tour sont rendus autrement), le rappel de +// budget en message à part fait recalculer toute la boucle d'outils du tour. +// Posé au bout du dernier résultat d'outil, il ne coûte que lui-même. +// +// Le prix : une consigne glissée dans une sortie d'outil, que les modèles +// entraînés contre l'injection de prompt peuvent suivre moins bien — d'où +// l'opt-in, à n'activer qu'après avoir comparé l'obéissance aux rappels. +// +// Moteur local seulement, et seulement si la sonde de gabarit +// (chat_tplprobe.go) a conclu « préfixe instable » pour CE modèle : « inconnu » +// ou un autre modèle laissent le message à part. +func nudgeInToolOn(cfg map[string]string, ep chatEndpoint) bool { + switch strings.ToLower(strings.TrimSpace(cfg["NUDGE_IN_TOOL"])) { + case "on", "1", "true", "yes", "oui": + default: + return false + } + if ep.External { + return false + } + r, ok := tplCapsCurrent() + return ok && r.PrefixStable == tplNo && r.Model != "" && r.Model == reasoningEchoModel(cfg) +} + +// nudgeIntoTool ajoute le rappel au bout du dernier résultat d'outil, dans la +// vue du modèle ET dans l'historique — le même message, modifié des deux côtés +// à l'identique, pour que le tour suivant rejoue les mêmes octets. Ce résultat +// n'est pas encore parti au moteur : rien de ce qu'il a en cache ne bouge. +// ok=false (dernier message d'une autre forme, historique réécrit entre-temps +// par une compaction) : l'appelant passe par appendNudge. Copies, jamais de +// modification en place. +func nudgeIntoTool(messages, extra []Message, text string) ([]Message, []Message, bool) { + n, e := len(messages), len(extra) + if n == 0 || e == 0 { + return messages, extra, false + } + last, kept := messages[n-1], extra[e-1] + s, ok := last.Content.(string) + ks, kok := kept.Content.(string) + if !ok || !kok || s != ks || last.Role != "tool" || kept.Role != "tool" || last.ToolCallID != kept.ToolCallID { + return messages, extra, false + } + last.Content = s + "\n\n" + text + messages = append(messages[:n-1:n-1], last) + extra = append(extra[:e-1:e-1], last) + return messages, extra, true +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index d9b8c06..cc4504e 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -110,6 +110,15 @@ var configTemplate = []struct{ key, help string }{ "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"}, + {"KEEP_TURN_IMAGES", "zone grise — on = garder dans l'historique les images montrées par les outils (captures, see_image), " + + "rangées par référence, au lieu de les oublier en fin de tour : le tour suivant ne recalcule plus la boucle d'outils " + + "depuis la première capture. Coût mesuré par le moteur, total borné à 10 % de la fenêtre, retirées d'abord à toute " + + "compaction (qui arrive donc plus tôt) ; vision active seulement, preset externe s'il déclare la vision (images " + + "refacturées à chaque tour). Vide/off (défaut) = images éphémères, requête inchangée"}, + {"NUDGE_IN_TOOL", "zone grise — on = le rappel de budget d'outils part au bout du dernier résultat d'outil au lieu d'un " + + "message à part, sur le moteur local et seulement si la sonde de gabarit dit que le rendu bouge quand un message " + + "s'ajoute (Qwen3.5) : la boucle d'outils du tour n'est plus recalculée. Un modèle peut moins bien suivre une consigne " + + "lue dans une sortie d'outil : à comparer avant de l'adopter. Vide/off (défaut) = message à part"}, {"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"},