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:
MichaelandClaude Opus 5.5 committed 2026-10-04 09:46:01 +02:00
1 parent b0c53e32af
commit f74f64ed90
9 files changed
+1153 -5

No files matched your search

+68 -4
View File
@@ -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)