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,
|
||||
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/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
"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
|
||||
|
||||
@@ -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 <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 != "" {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user