mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +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
@@ -433,6 +433,40 @@ Ajoutées par ce fork :
|
|||||||
vidé. Le filet réactif (prompt refusé), le bouton « compacter », les tâches,
|
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
|
les sous-agents et un preset externe gardent le chemin d'avant. Visible dans la
|
||||||
télémétrie sous `compact`.
|
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
|
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||||
|
|||||||
@@ -180,7 +180,9 @@ func msgTokens(m Message) int {
|
|||||||
for _, tc := range m.ToolCalls {
|
for _, tc := range m.ToolCalls {
|
||||||
n += (len(tc.Function.Name) + len(tc.Function.Arguments)) / 4
|
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
|
// 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
|
// 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
|
// la requête de résumé. refused : un résumé a été obtenu, mais la réduction
|
||||||
// reste sous les 20 % exigés.
|
// 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) {
|
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 à
|
// 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
|
// 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% →
|
// 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)
|
target := int(float64(ctxWindow()) * 0.6 / ratio)
|
||||||
out := append([]Message(nil), msgs...)
|
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 {
|
for i := range out {
|
||||||
if out[i].ReasoningContent == "" && out[i].ReasoningModel == "" {
|
if out[i].ReasoningContent == "" && out[i].ReasoningModel == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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é :
|
// 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
|
// il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la
|
||||||
// bannière de progression émise par compactAndPublish.
|
// 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
|
// Estimation de début de tour, comparée au premier compte réel du moteur
|
||||||
// (journal [ctx], seulement au-delà de 2 % d'écart).
|
// (journal [ctx], seulement au-delà de 2 % d'écart).
|
||||||
startEst := ctxUsed
|
startEst := ctxUsed
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,6 +235,7 @@ func projSnapKey(caps Caps, cfg map[string]string) string {
|
|||||||
var projSnapIgnoredKeys = map[string]bool{
|
var projSnapIgnoredKeys = map[string]bool{
|
||||||
"PROJ_SNAPSHOT": true, "PREWARM": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true,
|
"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,
|
"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
|
// projSnapSysHash : empreinte du bloc système commun et des outils. S'ils
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ type Message struct {
|
|||||||
// raisonnement quand il ne doit pas partir. Vides : JSON inchangé.
|
// raisonnement quand il ne doit pas partir. Vides : JSON inchangé.
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
ReasoningModel string `json:"reasoning_model,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 {
|
type ToolCall struct {
|
||||||
@@ -1152,6 +1163,9 @@ func turnEchoPolicy(off bool) echoPolicy {
|
|||||||
// renvoyé selon pol — et le nombre de raisonnements renvoyés.
|
// renvoyé selon pol — et le nombre de raisonnements renvoyés.
|
||||||
func wireMessages(messages []Message, toolChoiceNone bool, pol echoPolicy) ([]Message, int) {
|
func wireMessages(messages []Message, toolChoiceNone bool, pol echoPolicy) ([]Message, int) {
|
||||||
sent := normalizeSystemMessages(messages)
|
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 {
|
if toolChoiceNone {
|
||||||
sent = withTrailingHint(sent, toolsOffHint)
|
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
|
// runChatTools est runChat avec les outils déjà calculés par prepareTurn : ceux
|
||||||
// que décrit le préambule, à l'octet près.
|
// 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) {
|
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
|
var extra []Message
|
||||||
// Bloc projet figé de la discussion (PROJ_SNAPSHOT, chat_projsnap.go) : nil
|
// 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 —
|
// 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,
|
// 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
|
// 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.
|
// 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() {
|
newHistory := func() {
|
||||||
if snapTurn != nil {
|
if snapTurn != nil {
|
||||||
messages = snapTurn.refresh(messages)
|
messages = snapTurn.refresh(messages)
|
||||||
}
|
}
|
||||||
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
|
publishHistory()
|
||||||
}
|
}
|
||||||
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
|
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
|
||||||
// in streaming mode: the model's <think> block (opened by the chat template)
|
// in streaming mode: the model's <think> 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 != "" {
|
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
|
||||||
budgetNudges++
|
budgetNudges++
|
||||||
logBudget(toolRuns, budget, 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,
|
// Appels d'outils exécutables pour cette complétion : outils annoncés,
|
||||||
// et ni coupés ni neutralisés par tool_choice « none ».
|
// 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 {
|
if err != nil {
|
||||||
return extra, err
|
return extra, err
|
||||||
}
|
}
|
||||||
|
kt.sending(len(messages)) // KEEP_TURN_IMAGES : rien sans la clé
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
// ⚠️ Surtout pas authHeader : celui-ci pose la clé du serveur LOCAL, et
|
// ⚠️ 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
|
// 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
|
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" {
|
if finishReason == "length" {
|
||||||
logCtx("finish=length prompt=%d généré=%d raisonnement=%d fenêtre=%d", stats.PromptTokensTotal, stats.GenTokens, stats.ReasoningTokens, ctxWindow())
|
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
|
// 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
|
// fenêtre de 32 768, plus aucun tour ne passait). Le modèle regarde
|
||||||
// l'image MAINTENANT et sa description textuelle, elle, reste.
|
// 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 tc.Function.Name == "web_screenshot" {
|
||||||
if rel := capturedRelPath(result); rel != "" {
|
if rel := capturedRelPath(result); rel != "" {
|
||||||
if imgMsg, ok := screenshotImageMessage(rel); ok {
|
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
|
// dépasser la fenêtre pour de bon. Le modèle regarde l'image
|
||||||
// maintenant ; ce qu'il en dit, lui, reste.
|
// maintenant ; ce qu'il en dit, lui, reste.
|
||||||
if visionImg != nil {
|
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 :
|
// 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 {
|
if ptag.kind == perfMain {
|
||||||
mainConv = ptag.conv
|
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) {
|
if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used, messages) {
|
||||||
opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol,
|
opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol,
|
||||||
disableTools || toolChoiceNone)
|
disableTools || toolChoiceNone)
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ func isLokiInjected(m Message) bool {
|
|||||||
if m.Role != "user" {
|
if m.Role != "user" {
|
||||||
return false
|
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)
|
s, ok := m.Content.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
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.
|
// Dernier message d'une autre forme (cas théorique) : un message à part.
|
||||||
return append(out, Message{Role: "user", Content: lokiNotePrefix + hint})
|
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
|
||||||
|
}
|
||||||
@@ -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 " +
|
"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 ; " +
|
"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"},
|
"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_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
|
||||||
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
|
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
|
||||||
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
|
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
|
||||||
|
|||||||
Reference in new issue
Block a user