mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 15:38:12 +02:00
Cache : KEEP_TURN_IMAGES garde les images d'outils et NUDGE_IN_TOOL range le rappel de budget, en opt-in
Une capture ou une image vue par see_image ne vivait que le temps du tour : au tour suivant elle manquait à l'historique, le préfixe en cache divergeait à elle et toute la boucle d'outils qui suivait était recalculée. Sur Qwen3.5, le rappel de budget en message user faisait de même pour le tour en cours : le nouveau message devient la « dernière question » et tout le tour est rendu autrement. Deux clés, off par défaut et étiquetées zone grise ; sans elles, requêtes identiques à l'octet près (testé). - KEEP_TURN_IMAGES : image gardée par référence (chatimg, chiffrée si la mémoire l'est), relue à l'identique avant d'être gardée, vision active revérifiée à chaque tour ; preset externe seulement s'il déclare la vision - coût mesuré par le moteur (écart de prompt_tokens moins le texte ajouté) ; image non mesurable = éphémère comme avant ; total sous 10 % de la fenêtre, la première qui dépasse et les suivantes du tour restent éphémères - compaction, réduction forcée et début ou milieu de tour au seuil retirent ces images d'abord (légende et imageLostMarker gardés), le résumé ne suit que s'il reste nécessaire ; clé retirée ou vision perdue : retirées au tour - relais marqué ImgRelay (persisté, retiré à l'envoi) : ni demande réinjectée ni preuve de demande servie, fil rouvert compris ; une image non gardée n'entre plus dans l'historique par une compaction en cours de tour - discussion seulement (ni tâche, ni sous-agent, ni vérification) ; fichier d'image rafraîchi à chaque usage, l'élagage de 24 h ne le prend pas - NUDGE_IN_TOOL : rappel de budget au bout du dernier résultat d'outil, même message persisté ; moteur local, sonde de gabarit « préfixe instable » pour ce modèle ; sinon, ou forme inattendue, message à part comme avant Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b0c53e32af
commit
f74f64ed90
9 files changed
+1153
-5
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user