mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Lot 32 : images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
KEEP_TURN_IMAGES ne garde plus la conversation au Go. L'état du tour vit dans la façade ; les fichiers et la vision restent au Go. Go (facade_keepimg.go) : - /internal/v1/turn/keepimg : vision réellement active, budget des images gardées ; - /internal/v1/turn/keepimg/store : une image relayée rangée par référence (refRelayImage), relue à l'octet près. Rust : - chat/keepimg.rs : port de keepImages (image à l'essai, mesure par le moteur répartie au prorata des octets, budget, publication, fin de tour). - La boucle du tour : budget demandé pour le fil principal, image rangée par le Go, historique réécrit publié sans les relais non gardés ; les images gardées partent avant tout résumé (filet réactif, en cours de tour, fenêtre pleine). - Début et fin de tour : retirées si le contexte le demande ou si elles ne doivent plus partir (clé retirée, vision absente). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
17 files changed
+1435
-37
No files matched your search
@@ -227,7 +227,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
|
||||
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
|
||||
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
|
||||
| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification). `go` : la rend au Go (porte de sortie). Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES` ou `PREWARM`, le Go la garde de toute façon (décidé au démarrage) | Rust |
|
||||
| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) et les images gardées (`KEEP_TURN_IMAGES`). `go` : la rend au Go (porte de sortie). Avec `PROJ_SNAPSHOT` ou `PREWARM`, le Go la garde de toute façon (décidé au démarrage) | Rust |
|
||||
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 32 — Images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Sans la clé, une image montrée par un outil (capture, `see_image`) est
|
||||
éphémère. Le modèle la voit pendant le tour, puis elle disparaît de
|
||||
l'historique. Le tour suivant renvoie donc un fil où elle manque : le
|
||||
préfixe en cache diverge à elle, et toute la boucle d'outils qui suivait est
|
||||
recalculée.
|
||||
|
||||
`KEEP_TURN_IMAGES` (`chat_keep_images.go`) garde l'image telle qu'envoyée,
|
||||
sous conditions :
|
||||
|
||||
- vision réellement active ;
|
||||
- image rangée par référence et relue à l'octet près ;
|
||||
- coût **mesuré** par le moteur, jamais estimé ;
|
||||
- toutes les images gardées tiennent dans 10 % de la fenêtre.
|
||||
|
||||
Les images gardées partent avant tout résumé. La clé gardait la conversation
|
||||
au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
### Go (`facade_keepimg.go`)
|
||||
|
||||
- `/internal/v1/turn/keepimg` rend deux choses :
|
||||
- si la vision est réellement active (`visionEnabled`, `engineSeesImages`) ;
|
||||
- le budget des images gardées (`keepImgBudget`).
|
||||
- `/internal/v1/turn/keepimg/store` range une image relayée par référence
|
||||
(`refRelayImage`) :
|
||||
- fichier chiffré si la mémoire l'est ;
|
||||
- date rafraîchie ;
|
||||
- relu à l'octet près.
|
||||
|
||||
Il rend le message qui la porte ainsi et son poids en octets, ou un refus.
|
||||
- `KEEP_TURN_IMAGES` ne garde plus la conversation.
|
||||
|
||||
### Rust
|
||||
|
||||
- `chat/keepimg.rs` porte l'état du tour (`keepImages`) :
|
||||
- `relay` : image gardée à l'essai, dans `messages` et `extra` ;
|
||||
- `sending` ;
|
||||
- `observe` : mesure de la requête qui l'a montrée la première, partage au
|
||||
prorata des octets, seuil de 16 jetons, budget ;
|
||||
- `rewritten` ;
|
||||
- `publishable` ;
|
||||
- `finish` : une image à l'essai jamais mesurée redevient éphémère.
|
||||
|
||||
Une image refusée arrête le tour pour les suivantes.
|
||||
- La boucle du tour :
|
||||
- `TurnHost::keep_images` donne le budget, mais seulement pour le fil
|
||||
principal d'une discussion, avec la clé et la vision active ;
|
||||
- `TurnHost::keep_image` range l'image par le Go ;
|
||||
- l'historique réécrit en cours de tour est publié sans les relais non
|
||||
gardés.
|
||||
- Les images gardées partent d'abord, avant tout résumé :
|
||||
- au filet réactif (prompt trop long) : requête rejouée sans elles ;
|
||||
- en cours de tour, au seuil : la compaction ne suit que si elle reste
|
||||
nécessaire ;
|
||||
- sur une complétion coupée par une fenêtre pleine : rejouée sans résumé
|
||||
si leur retrait suffit.
|
||||
- Début et fin de tour (`keptImagesTurnStart`) : les images gardées sont
|
||||
retirées si le contexte le demande, ou si elles ne doivent plus partir
|
||||
(clé retirée, vision absente).
|
||||
|
||||
La compaction et la réduction forcée retiraient déjà les images gardées
|
||||
d'abord (`chat/compact.rs`).
|
||||
|
||||
## Tests
|
||||
|
||||
- `chat/keepimg` :
|
||||
- clé ;
|
||||
- gardée au coût mesuré ;
|
||||
- budget ;
|
||||
- sans mesure (pas d'usage, complétion coupée, pas de base) ;
|
||||
- requête relancée ;
|
||||
- rangement refusé ;
|
||||
- partage entre deux images ;
|
||||
- publication, réécriture, fin de tour, budget plein avant rangement.
|
||||
- La boucle du tour :
|
||||
- requête identique avec et sans la clé, image gardée par référence au
|
||||
coût mesuré ;
|
||||
- refus : budget, pas d'usage, rangement refusé ;
|
||||
- image à l'essai en fin de tour ;
|
||||
- retrait en cours de tour, au filet réactif et sur une fenêtre pleine, à
|
||||
chaque fois sans résumé.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- image rangée par le Go, vue en data-URL par le moteur, gardée avec son
|
||||
coût ;
|
||||
- clé retirée : l'image part au début du tour suivant, légende et marque
|
||||
gardées.
|
||||
- Le Go (`facade_keepimg_test.go`) :
|
||||
- état sans projecteur puis vu par le moteur, budget ;
|
||||
- rangement relu à l'octet près ;
|
||||
- refus : texte seul, URL distante, format inconnu, base64 illisible, deux
|
||||
images ;
|
||||
- corps illisible, GET refusé.
|
||||
@@ -48,17 +48,16 @@ func facadeChat() bool {
|
||||
|
||||
// facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y
|
||||
// en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet
|
||||
// figé, images gardées, préchauffage). NUDGE_IN_TOOL n'en est plus (lot 23) :
|
||||
// la façade a sa sonde de gabarit ; ni les motifs de relance personnalisés
|
||||
// (retry_patterns, lot 30) : la façade les lit et les compile ; ni SIDE_SLOT et
|
||||
// SLOT_PERSIST (lot 31) : la façade demande son slot avant chaque requête
|
||||
// (facade_slots.go).
|
||||
// figé, préchauffage). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa
|
||||
// sonde de gabarit ; ni les motifs de relance personnalisés (retry_patterns,
|
||||
// lot 30) : la façade les lit et les compile ; ni SIDE_SLOT et SLOT_PERSIST
|
||||
// (lot 31) : la façade demande son slot avant chaque requête
|
||||
// (facade_slots.go) ; ni KEEP_TURN_IMAGES (lot 32) : la façade tient l'état
|
||||
// du tour, le Go range les images (facade_keepimg.go).
|
||||
func facadeChatBlocker(cfg map[string]string) string {
|
||||
switch {
|
||||
case projSnapEnabled(cfg):
|
||||
return "PROJ_SNAPSHOT"
|
||||
case keepTurnImagesOn(cfg):
|
||||
return "KEEP_TURN_IMAGES"
|
||||
case prewarmMode(cfg) != "":
|
||||
return "PREWARM"
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
|
||||
t.Fatalf("détenteur : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Posé après la décision : sans effet avant le redémarrage.
|
||||
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
|
||||
if err := SetConfigKey("PREWARM", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !facadeChat() {
|
||||
@@ -285,10 +285,10 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
|
||||
}
|
||||
oublierDecisionFacade(t)
|
||||
if facadeChat() {
|
||||
t.Fatal("KEEP_TURN_IMAGES posé : la conversation doit rester au Go")
|
||||
t.Fatal("PREWARM posé : la conversation doit rester au Go")
|
||||
}
|
||||
w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "")
|
||||
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "KEEP_TURN_IMAGES") {
|
||||
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "PREWARM") {
|
||||
t.Fatalf("détenteur : %s", w.Body.String())
|
||||
}
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
@@ -299,7 +299,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
|
||||
}
|
||||
for k, want := range map[string]string{
|
||||
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "",
|
||||
"SLOT_PERSIST": "", "PREWARM": "PREWARM",
|
||||
"SLOT_PERSIST": "", "KEEP_TURN_IMAGES": "", "PREWARM": "PREWARM",
|
||||
} {
|
||||
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
|
||||
t.Errorf("%s : %q", k, got)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// facade_keepimg.go — KEEP_TURN_IMAGES pour le tour tenu par la façade (lot 32
|
||||
// de la migration). L'état du tour (images à l'essai, mesure, budget) vit dans
|
||||
// la façade ; ce qui touche aux fichiers et à la vision reste ici :
|
||||
//
|
||||
// - /internal/v1/turn/keepimg : la vision est-elle réellement active
|
||||
// (visionEnabled, engineSeesImages) et le budget des images gardées
|
||||
// (keepImgBudget) ;
|
||||
// - /internal/v1/turn/keepimg/store : une image relayée rangée par
|
||||
// référence (refRelayImage : fichier chiffré si la mémoire l'est, date
|
||||
// rafraîchie, relu à l'octet près).
|
||||
|
||||
// handleInternalTurnKeepImg : POST {} → {"vision":bool,"budget":n}.
|
||||
func handleInternalTurnKeepImg(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{}
|
||||
if !readInternalJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{
|
||||
"vision": visionEnabled() && engineSeesImages(),
|
||||
"budget": keepImgBudget(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleInternalTurnKeepImgStore : POST {"msg":Message} →
|
||||
// {"ok":true,"msg":Message,"weight":octets} ou {"ok":false} (format inconnu,
|
||||
// disque plein, mémoire verrouillée, relecture différente : l'image reste
|
||||
// éphémère).
|
||||
func handleInternalTurnKeepImgStore(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Msg Message `json:"msg"`
|
||||
}
|
||||
if !readInternalJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
m, weight, ok := refRelayImage(in.Msg)
|
||||
if !ok {
|
||||
sendJSON(w, http.StatusOK, map[string]any{"ok": false})
|
||||
return
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "msg": m, "weight": weight})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// L'état que la façade demande au début d'un tour : vision réellement active
|
||||
// (projecteur déclaré ET vu par le moteur) et budget des images gardées.
|
||||
func TestFacadeKeepImgEtat(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
if err := SetConfigKey("CTX", "2000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etat := func() (bool, float64) {
|
||||
w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg", `{}`)
|
||||
var out struct {
|
||||
Vision bool `json:"vision"`
|
||||
Budget float64 `json:"budget"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 {
|
||||
t.Fatalf("état : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
return out.Vision, out.Budget
|
||||
}
|
||||
if v, b := etat(); v || b != 200 {
|
||||
t.Fatalf("sans projecteur : vision=%v budget=%v", v, b)
|
||||
}
|
||||
visionServer(t, "")
|
||||
if v, _ := etat(); !v {
|
||||
t.Fatal("projecteur vu par le moteur : vision attendue")
|
||||
}
|
||||
if w := appelOAIInterne(t, "GET", "/internal/v1/turn/keepimg", ""); w.Code != 405 {
|
||||
t.Fatalf("GET : %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Une image relayée rangée par référence, relue à l'octet près ; sinon elle
|
||||
// reste éphémère.
|
||||
func TestFacadeKeepImgRangement(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
store := func(content string) map[string]any {
|
||||
w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg/store", `{"msg":{"role":"user","content":`+content+`}}`)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 {
|
||||
t.Fatalf("rangement : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := store(`[{"type":"text","text":"Voici la capture demandée."},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]`)
|
||||
if out["ok"] != true || out["weight"] != float64(8) {
|
||||
t.Fatalf("rangée : %v", out)
|
||||
}
|
||||
msg := out["msg"].(map[string]any)
|
||||
parts := msg["content"].([]any)
|
||||
if parts[0].(map[string]any)["text"] != "Voici la capture demandée." {
|
||||
t.Fatalf("légende : %v", parts)
|
||||
}
|
||||
ref := parts[1].(map[string]any)["image_url"].(map[string]any)["url"].(string)
|
||||
if !strings.HasPrefix(ref, imgRefScheme) {
|
||||
t.Fatalf("pas par référence : %s", ref)
|
||||
}
|
||||
if back, ok := chatImageDataURL(ref); !ok || back != "data:image/png;base64,iVBORw0KGgo=" {
|
||||
t.Fatalf("relecture : %v %.40s", ok, back)
|
||||
}
|
||||
if ents, _ := os.ReadDir(chatImgDir()); len(ents) != 1 {
|
||||
t.Fatalf("%d fichier(s) rangé(s)", len(ents))
|
||||
}
|
||||
for _, c := range []string{
|
||||
`"texte seul"`,
|
||||
`[{"type":"image_url","image_url":{"url":"https://exemple.org/a.png"}}]`,
|
||||
`[{"type":"image_url","image_url":{"url":"data:image/x-inconnu;base64,AAAA"}}]`,
|
||||
`[{"type":"image_url","image_url":{"url":"data:image/png;base64,pas du base64"}}]`,
|
||||
`[{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]`,
|
||||
} {
|
||||
if out := store(c); out["ok"] != false {
|
||||
t.Errorf("%s : %v", c, out)
|
||||
}
|
||||
}
|
||||
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg/store", `{`); w.Code != 400 {
|
||||
t.Fatalf("corps illisible : %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,10 @@ func withInternalAPI(next http.Handler) http.Handler {
|
||||
handleInternalTurnPerf(w, r)
|
||||
case "/internal/v1/turn/slot":
|
||||
handleInternalTurnSlot(w, r)
|
||||
case "/internal/v1/turn/keepimg":
|
||||
handleInternalTurnKeepImg(w, r)
|
||||
case "/internal/v1/turn/keepimg/store":
|
||||
handleInternalTurnKeepImgStore(w, r)
|
||||
case "/internal/v1/tool/exec":
|
||||
handleInternalToolExec(w, r)
|
||||
case "/internal/v1/tool/context":
|
||||
|
||||
@@ -416,7 +416,7 @@ pub(crate) fn is_loki_injected(m: &Message) -> bool {
|
||||
|
||||
/// `dropKeptImages` : copie où chaque image gardée devient sa légende suivie
|
||||
/// d'[`IMAGE_LOST_MARKER`], et coût libéré. `None` : aucune image gardée.
|
||||
fn drop_kept_images(msgs: &[Message]) -> Option<(Vec<Message>, i64)> {
|
||||
pub fn drop_kept_images(msgs: &[Message]) -> Option<(Vec<Message>, i64)> {
|
||||
if !msgs.iter().any(|m| m.img_tokens > 0) {
|
||||
return None;
|
||||
}
|
||||
@@ -441,7 +441,7 @@ fn drop_kept_images(msgs: &[Message]) -> Option<(Vec<Message>, i64)> {
|
||||
Some((out, freed))
|
||||
}
|
||||
|
||||
fn log_kept_images_dropped(phase: &str, freed: i64) {
|
||||
pub fn log_kept_images_dropped(phase: &str, freed: i64) {
|
||||
eprintln!("[images] {phase} : images gardées retirées ({freed} jetons)");
|
||||
}
|
||||
|
||||
|
||||
@@ -22,15 +22,17 @@
|
||||
//! corrections du builder) suit le tour, avant la compaction de fin
|
||||
//! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé.
|
||||
//!
|
||||
//! Écarts assumés (restent au Go) : PROJ_SNAPSHOT et KEEP_TURN_IMAGES
|
||||
//! (refusés par `/turn/view`), préchauffage. La télémétrie des complétions
|
||||
//! Écarts assumés (restent au Go) : PROJ_SNAPSHOT (refusé par
|
||||
//! `/turn/view`), préchauffage. Les images gardées (KEEP_TURN_IMAGES, lot 32)
|
||||
//! partent en début et en fin de tour quand il le faut
|
||||
//! ([`ChatService::kept_images_turn_start`]). La télémétrie des complétions
|
||||
//! (tours, vérifications, résumés) rejoint l'anneau du Go
|
||||
//! (`/internal/v1/turn/perf`) ; les motifs `retry_patterns` personnalisés
|
||||
//! sont lus au démarrage ([`retry_patterns`]). Chaque requête au moteur local
|
||||
//! part sur le slot que rend le Go (SIDE_SLOT, rechargement SLOT_PERSIST,
|
||||
//! lot 31).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -42,14 +44,18 @@ use tokio::sync::watch;
|
||||
|
||||
use super::compact::{
|
||||
CompactCaps, CompactConfig, CompactOpts, Compactor, TurnView, compact_needed, compact_opts_for,
|
||||
ctx_pending, estimate_tokens, log_compact, log_ctx_estimate, strip_context_updates,
|
||||
ctx_pending, drop_kept_images, estimate_tokens, log_compact, log_ctx_estimate,
|
||||
log_kept_images_dropped, strip_context_updates,
|
||||
};
|
||||
use super::engine::EngineHttp;
|
||||
use super::history::{
|
||||
drop_stray_nudges, history_from_view, inject_prefix, remind_read_mem_pages, tool_used_delta,
|
||||
};
|
||||
use super::host::{ChatShared, EngineSummarizer, GoTurnHost, StoreRecall, TurnCompact};
|
||||
use super::host::{
|
||||
ChatShared, EngineSummarizer, GoTurnHost, StoreRecall, TurnCompact, keep_images_state,
|
||||
};
|
||||
use super::journal::Delta;
|
||||
use super::keepimg::{has_kept_images, keep_turn_images_on};
|
||||
use super::lease::{EngineLease, LEASE_TTL, LeaseError, LeaseSignal, engine_stop};
|
||||
use super::sessions::{GoSessionHooks, Sessions};
|
||||
use super::turn::{Cancel, TurnConfig, run_turn};
|
||||
@@ -410,6 +416,55 @@ impl ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/// `keptImagesTurnStart` : début (ou fin) d'un tour. Les images gardées
|
||||
/// (KEEP_TURN_IMAGES) 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). Rend
|
||||
/// l'historique et le contexte à jour.
|
||||
async fn kept_images_turn_start(
|
||||
&self,
|
||||
epoch: i64,
|
||||
cfg: &HashMap<String, String>,
|
||||
ccfg: &CompactConfig,
|
||||
msgs: Vec<Message>,
|
||||
ctx_used: i64,
|
||||
peak: i64,
|
||||
) -> (Vec<Message>, i64) {
|
||||
if !has_kept_images(&msgs) {
|
||||
return (msgs, ctx_used);
|
||||
}
|
||||
if keep_turn_images_on(cfg)
|
||||
&& matches!(keep_images_state(&self.shared.store).await, Some((true, _)))
|
||||
&& !compact_needed(ccfg, &msgs, ctx_used, peak)
|
||||
{
|
||||
return (msgs, ctx_used);
|
||||
}
|
||||
let Some((out, freed)) = drop_kept_images(&msgs) else {
|
||||
return (msgs, ctx_used);
|
||||
};
|
||||
log_kept_images_dropped("début-tour", freed);
|
||||
let ctx_used = if ctx_used > 0 {
|
||||
(ctx_used - freed).max(1)
|
||||
} else {
|
||||
ctx_used
|
||||
};
|
||||
let conv = self.sessions.conv();
|
||||
let cur = conv
|
||||
.with_state_if(epoch, |st| {
|
||||
st.messages = out.clone();
|
||||
if st.ctx_used > 0 {
|
||||
st.ctx_used = (st.ctx_used - freed).max(1);
|
||||
}
|
||||
st.ctx_used
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if cur > 0 {
|
||||
conv.append_delta(epoch, obj("ctx_used", cur));
|
||||
}
|
||||
(out, ctx_used)
|
||||
}
|
||||
|
||||
/// `compactAndPublish` : bannière, journal, installation du résultat,
|
||||
/// jauge de contexte. Rend l'historique compacté, `None` s'il n'a pas
|
||||
/// changé.
|
||||
@@ -718,10 +773,15 @@ impl ChatService {
|
||||
let peak = std::mem::take(&mut st.gen_peak);
|
||||
(msgs, used, peak)
|
||||
});
|
||||
let Some((mut msgs, ctx_used, peak)) = snap else {
|
||||
let Some((msgs, ctx_used, peak)) = snap else {
|
||||
lease.release().await;
|
||||
return;
|
||||
};
|
||||
// KEEP_TURN_IMAGES : images gardées retirées d'abord si le contexte le
|
||||
// demande ou si elles ne doivent plus partir.
|
||||
let (mut msgs, ctx_used) = self
|
||||
.kept_images_turn_start(epoch, &cfg, &ccfg, msgs, ctx_used, peak)
|
||||
.await;
|
||||
|
||||
// Compaction de début de tour.
|
||||
let mut start_est = ctx_used;
|
||||
@@ -972,7 +1032,13 @@ impl ChatService {
|
||||
}
|
||||
|
||||
// Compaction de fin de tour : la jauge retombe tout de suite, le tour
|
||||
// suivant démarre avec de la marge. Pas après un stop.
|
||||
// suivant démarre avec de la marge. Pas après un stop. Les images
|
||||
// gardées partent d'abord, comme en début de tour.
|
||||
if !cancel.is_cancelled() {
|
||||
(msgs, ctx_used) = self
|
||||
.kept_images_turn_start(epoch, &cfg, &ccfg, msgs, ctx_used, peak)
|
||||
.await;
|
||||
}
|
||||
if !cancel.is_cancelled()
|
||||
&& compact_needed(&ccfg, &msgs, ctx_used, peak)
|
||||
&& !self
|
||||
|
||||
@@ -420,6 +420,22 @@ pub async fn engine_begin(store: &Store, main_conv: &str, side: bool) -> Option<
|
||||
}
|
||||
}
|
||||
|
||||
/// KEEP_TURN_IMAGES (lot 32) : la vision est-elle réellement active
|
||||
/// (projecteur déclaré et vu par le moteur) et le budget des images gardées.
|
||||
/// Go injoignable : `None` (images éphémères).
|
||||
pub async fn keep_images_state(store: &Store) -> Option<(bool, i64)> {
|
||||
match store
|
||||
.internal_post("/internal/v1/turn/keepimg", &json!({}))
|
||||
.await
|
||||
{
|
||||
Ok((code, v)) if code.is_success() => Some((
|
||||
v["vision"].as_bool().unwrap_or(false),
|
||||
v["budget"].as_i64().unwrap_or(0),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rend le ticket « en vol » au Go quand la réponse du moteur est lue ou
|
||||
/// abandonnée (`engineRequestBeginSide` … `end`).
|
||||
pub struct GateTicket {
|
||||
@@ -704,6 +720,35 @@ impl TurnHost for GoTurnHost {
|
||||
self.shared.echo_refusals.refuse(model);
|
||||
}
|
||||
|
||||
async fn keep_images(&self) -> Option<i64> {
|
||||
// Fil principal d'une discussion seulement : la vérification ne
|
||||
// persiste pas son historique.
|
||||
if !super::keepimg::keep_turn_images_on(&self.cfg)
|
||||
|| self.perf_kind != super::perf::KIND_MAIN
|
||||
|| self.conv_id.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match keep_images_state(&self.shared.store).await {
|
||||
Some((true, budget)) => Some(budget),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn keep_image(&self, m: &Message) -> Option<(Message, i64)> {
|
||||
let (code, v) = self
|
||||
.shared
|
||||
.store
|
||||
.internal_post("/internal/v1/turn/keepimg/store", &json!({ "msg": m }))
|
||||
.await
|
||||
.ok()?;
|
||||
if !code.is_success() || v["ok"] != true {
|
||||
return None;
|
||||
}
|
||||
let msg: Message = serde_json::from_value(v["msg"].clone()).ok()?;
|
||||
Some((msg, v["weight"].as_i64().unwrap_or(0)))
|
||||
}
|
||||
|
||||
async fn engine_slot(&self, ep: &ChatEndpoint) -> Option<u8> {
|
||||
if ep.external {
|
||||
self.side_slot.store(false, Ordering::SeqCst);
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! KEEP_TURN_IMAGES pour le tour tenu par la façade (lot 32) — port de
|
||||
//! l'état du tour de `chat_keep_images.go` (`keepImages`).
|
||||
//!
|
||||
//! Sans la clé, une image montrée par un outil (capture, `see_image`) est
|
||||
//! ÉPHÉMÈRE : vue pendant le tour, absente de l'historique. Avec la clé, elle
|
||||
//! est gardée telle qu'envoyée, sous conditions :
|
||||
//! - fil principal d'une discussion, vision réellement active (demandé au Go
|
||||
//! à chaque tour) ;
|
||||
//! - rangée par référence par le Go et relue à l'octet près ;
|
||||
//! - coût MESURÉ par le moteur (écart de `usage.prompt_tokens` entre deux
|
||||
//! requêtes, moins l'estimation du texte ajouté), jamais estimé ;
|
||||
//! - toutes les images gardées tiennent dans 10 % 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).
|
||||
//!
|
||||
//! Le retrait prioritaire par une compaction ou une réduction est dans
|
||||
//! [`super::compact`] ; celui du début de tour, de la boucle, ici et dans
|
||||
//! [`super::turn`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::compact::msg_tokens;
|
||||
use super::types::{Message, StatsEvent};
|
||||
use super::wire::content_parts;
|
||||
|
||||
/// En dessous, la mesure ne vaut rien (écart noyé dans l'erreur
|
||||
/// d'estimation du texte) : l'image reste éphémère.
|
||||
pub const KEEP_IMG_MIN_TOKENS: i64 = 16;
|
||||
|
||||
/// `keepTurnImagesOn`.
|
||||
pub fn keep_turn_images_on(cfg: &HashMap<String, String>) -> bool {
|
||||
matches!(
|
||||
cfg.get("KEEP_TURN_IMAGES")
|
||||
.map(|v| v.trim().to_lowercase())
|
||||
.as_deref(),
|
||||
Some("on" | "1" | "true" | "yes" | "oui")
|
||||
)
|
||||
}
|
||||
|
||||
/// `keptImageTokens` : coût total des images gardées.
|
||||
pub fn kept_image_tokens(msgs: &[Message]) -> i64 {
|
||||
msgs.iter().map(|m| m.img_tokens).sum()
|
||||
}
|
||||
|
||||
/// `hasKeptImages`.
|
||||
pub fn has_kept_images(msgs: &[Message]) -> bool {
|
||||
msgs.iter().any(|m| m.img_tokens > 0)
|
||||
}
|
||||
|
||||
fn has_image_part(m: &Message) -> bool {
|
||||
content_parts(m.content.as_ref())
|
||||
.iter()
|
||||
.any(|p| p.get("type").and_then(|t| t.as_str()) == Some("image_url"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Pend {
|
||||
msg_idx: usize,
|
||||
extra_idx: usize,
|
||||
weight: i64,
|
||||
}
|
||||
|
||||
/// L'état de KEEP_TURN_IMAGES pendant UN tour ; absent (`None` dans la
|
||||
/// boucle) sans la clé, hors fil principal ou sans vision.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KeepImages {
|
||||
/// `keepImgBudget` : part de la fenêtre que les images gardées occupent.
|
||||
budget: i64,
|
||||
/// Une image du tour n'a pas été gardée : les suivantes non plus.
|
||||
stopped: bool,
|
||||
/// Images de l'étape courante, à l'essai jusqu'à la mesure de la requête
|
||||
/// qui les montre la première.
|
||||
pend: Vec<Pend>,
|
||||
/// Numéro de cette requête, contexte que la complétion précédente laisse
|
||||
/// (`ctxAfter`), premier message ajouté depuis.
|
||||
pend_req: i64,
|
||||
pend_base: i64,
|
||||
pend_from: usize,
|
||||
/// Requêtes envoyées ; messages de la dernière.
|
||||
req_n: i64,
|
||||
sent_len: usize,
|
||||
/// `ctxAfter` de la dernière complétion complète (0 : inconnu).
|
||||
last_after: i64,
|
||||
}
|
||||
|
||||
impl KeepImages {
|
||||
pub fn new(budget: i64) -> Self {
|
||||
KeepImages {
|
||||
budget,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Faut-il ranger l'image (ni tour arrêté, ni budget déjà plein) ?
|
||||
pub fn wants(&self, messages: &[Message]) -> bool {
|
||||
!self.stopped && kept_image_tokens(messages) < self.budget
|
||||
}
|
||||
|
||||
/// `relay` : ajoute le message qui relaie une image d'outil. `stored` :
|
||||
/// l'image rangée par référence et son poids (octets), `None` si elle ne
|
||||
/// l'a pas été — elle reste alors éphémère, et le tour s'arrête là pour
|
||||
/// les suivantes. `from` : début des messages de l'étape.
|
||||
pub fn relay(
|
||||
&mut self,
|
||||
messages: &mut Vec<Message>,
|
||||
extra: &mut Vec<Message>,
|
||||
mut m: Message,
|
||||
from: usize,
|
||||
stored: Option<(Message, i64)>,
|
||||
) {
|
||||
m.img_relay = true;
|
||||
if self.stopped {
|
||||
messages.push(m);
|
||||
return;
|
||||
}
|
||||
let Some((mut r, weight)) = stored.filter(|(_, w)| *w > 0) else {
|
||||
self.stopped = true;
|
||||
messages.push(m);
|
||||
return;
|
||||
};
|
||||
r.img_relay = true;
|
||||
if self.pend.is_empty() {
|
||||
self.pend_req = self.req_n + 1;
|
||||
self.pend_base = self.last_after;
|
||||
self.pend_from = from;
|
||||
}
|
||||
messages.push(r.clone());
|
||||
extra.push(r);
|
||||
self.pend.push(Pend {
|
||||
msg_idx: messages.len() - 1,
|
||||
extra_idx: extra.len() - 1,
|
||||
weight,
|
||||
});
|
||||
}
|
||||
|
||||
/// `sending` : une requête part avec `n` messages.
|
||||
pub fn sending(&mut self, n: usize) {
|
||||
self.req_n += 1;
|
||||
self.sent_len = 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.
|
||||
pub fn observe(
|
||||
&mut self,
|
||||
complete: bool,
|
||||
st: &StatsEvent,
|
||||
messages: &mut [Message],
|
||||
extra: &mut Vec<Message>,
|
||||
) {
|
||||
let after = if complete && st.prompt_tokens_total > 0 {
|
||||
st.ctx_after()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if !self.pend.is_empty() {
|
||||
if !self.measure(after, st.prompt_tokens_total, messages, extra) {
|
||||
self.reject(extra);
|
||||
}
|
||||
self.pend.clear();
|
||||
}
|
||||
self.last_after = after;
|
||||
}
|
||||
|
||||
/// `measure` : répartit le coût mesuré entre les images à l'essai, au
|
||||
/// prorata de leur poids, et les garde si tout tient dans le budget.
|
||||
fn measure(
|
||||
&self,
|
||||
after: i64,
|
||||
prompt: i64,
|
||||
messages: &mut [Message],
|
||||
extra: &mut [Message],
|
||||
) -> bool {
|
||||
if after <= 0
|
||||
|| self.pend_base <= 0
|
||||
|| self.pend_req != self.req_n
|
||||
|| self.pend_from > self.sent_len
|
||||
|| self.sent_len > messages.len()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Ce que la requête a ajouté depuis la précédente, hors images.
|
||||
let text: i64 = messages[self.pend_from..self.sent_len]
|
||||
.iter()
|
||||
.filter(|m| !m.img_relay)
|
||||
.map(msg_tokens)
|
||||
.sum();
|
||||
let img = prompt - self.pend_base - text;
|
||||
let weight: i64 = self.pend.iter().map(|p| p.weight).sum();
|
||||
let n = self.pend.len() as i64;
|
||||
if weight <= 0 || img < KEEP_IMG_MIN_TOKENS * n {
|
||||
return false;
|
||||
}
|
||||
if kept_image_tokens(messages) + img > self.budget {
|
||||
return false;
|
||||
}
|
||||
let mut shares = Vec::with_capacity(self.pend.len());
|
||||
let mut left = img;
|
||||
for (i, p) in self.pend.iter().enumerate() {
|
||||
let share = if i == self.pend.len() - 1 {
|
||||
left
|
||||
} else {
|
||||
img * p.weight / weight
|
||||
};
|
||||
if share < KEEP_IMG_MIN_TOKENS {
|
||||
return false;
|
||||
}
|
||||
left -= share;
|
||||
shares.push(share);
|
||||
}
|
||||
if self.pend.iter().any(|p| {
|
||||
!messages.get(p.msg_idx).is_some_and(|m| m.img_relay)
|
||||
|| !extra.get(p.extra_idx).is_some_and(|m| m.img_relay)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
for (p, share) in self.pend.iter().zip(shares) {
|
||||
messages[p.msg_idx].img_tokens = share;
|
||||
extra[p.extra_idx].img_tokens = share;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `reject` : retire d'`extra` les images à l'essai — éphémères, comme
|
||||
/// sans la clé — et arrête le tour pour les suivantes.
|
||||
fn reject(&mut self, extra: &mut Vec<Message>) {
|
||||
if self.pend.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.stopped = true;
|
||||
let drop: HashSet<usize> = self.pend.iter().map(|p| p.extra_idx).collect();
|
||||
let mut i = 0;
|
||||
extra.retain(|_| {
|
||||
let keep = !drop.contains(&i);
|
||||
i += 1;
|
||||
keep
|
||||
});
|
||||
self.pend.clear();
|
||||
}
|
||||
|
||||
/// `rewritten` : l'historique vient d'être réécrit en cours de tour et
|
||||
/// `extra` repart de zéro. Les images à l'essai ne se mesurent plus :
|
||||
/// éphémères, et le tour s'arrête là pour les suivantes.
|
||||
pub fn rewritten(&mut self) {
|
||||
if !self.pend.is_empty() {
|
||||
self.stopped = true;
|
||||
}
|
||||
self.pend.clear();
|
||||
}
|
||||
|
||||
/// `finish` : fin du tour. Une image encore à l'essai n'a pas été
|
||||
/// mesurée : éphémère.
|
||||
pub fn finish(&mut self, extra: &mut Vec<Message>) {
|
||||
self.reject(extra);
|
||||
}
|
||||
}
|
||||
|
||||
/// `publishable` : l'historique publié par une réécriture en cours de tour,
|
||||
/// sans les relais dont l'image n'est pas gardée. Sans l'état : tel quel.
|
||||
pub fn publishable(kt: Option<&KeepImages>, msgs: &[Message]) -> Vec<Message> {
|
||||
if kt.is_none() {
|
||||
return msgs.to_vec();
|
||||
}
|
||||
msgs.iter()
|
||||
.filter(|m| !(m.img_relay && m.img_tokens == 0 && has_image_part(m)))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Tests de l'état KEEP_TURN_IMAGES d'un tour (cas de
|
||||
//! `chat_keep_images_test.go`).
|
||||
|
||||
use super::*;
|
||||
use crate::chat::compact::IMAGE_LOST_MARKER;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn um(s: &str) -> Message {
|
||||
Message {
|
||||
role: "user".into(),
|
||||
content: Some(json!(s)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn tm(s: &str) -> Message {
|
||||
Message {
|
||||
role: "tool".into(),
|
||||
tool_call_id: "v1".into(),
|
||||
content: Some(json!(s)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn img(url: &str) -> Message {
|
||||
Message {
|
||||
role: "user".into(),
|
||||
content: Some(json!([
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": {"url": url}}
|
||||
])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn stats(prompt: i64, gen_tokens: i64) -> StatsEvent {
|
||||
StatsEvent {
|
||||
prompt_tokens_total: prompt,
|
||||
gen_tokens,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn kept(tokens: i64) -> Message {
|
||||
let mut m = img("loki-img:aa.png");
|
||||
m.img_relay = true;
|
||||
m.img_tokens = tokens;
|
||||
m
|
||||
}
|
||||
|
||||
/// Une étape `see_image` : première complétion (prompt 1000, 20 générés),
|
||||
/// résultat d'outil, relais rangé ; rend (état, messages, extra, base du
|
||||
/// texte ajouté).
|
||||
fn step(k: &mut KeepImages, stored: bool) -> (Vec<Message>, Vec<Message>, i64) {
|
||||
let mut messages = vec![um("regarde img.png")];
|
||||
let mut extra = Vec::new();
|
||||
k.sending(messages.len());
|
||||
k.observe(true, &stats(1000, 20), &mut messages, &mut extra);
|
||||
let from = messages.len();
|
||||
let t = tm("[ok] image chargée : img.png");
|
||||
let text = msg_tokens(&t);
|
||||
messages.push(t.clone());
|
||||
extra.push(t);
|
||||
let s = stored.then(|| (img("loki-img:aa.png"), 1000));
|
||||
k.relay(
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
img("data:image/png;base64,AA=="),
|
||||
from,
|
||||
s,
|
||||
);
|
||||
k.sending(messages.len());
|
||||
(messages, extra, text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cle() {
|
||||
for (v, want) in [
|
||||
("on", true),
|
||||
(" OUI ", true),
|
||||
("1", true),
|
||||
("true", true),
|
||||
("yes", true),
|
||||
("off", false),
|
||||
("", false),
|
||||
("2", false),
|
||||
] {
|
||||
let cfg = HashMap::from([("KEEP_TURN_IMAGES".to_string(), v.to_string())]);
|
||||
assert_eq!(keep_turn_images_on(&cfg), want, "{v:?}");
|
||||
}
|
||||
assert!(!keep_turn_images_on(&HashMap::new()));
|
||||
}
|
||||
|
||||
/// Coût mesuré par le moteur : écart de prompt moins le texte ajouté ; gardée
|
||||
/// par référence, dans `messages` comme dans `extra`.
|
||||
#[test]
|
||||
fn gardee_et_mesuree() {
|
||||
let mut k = KeepImages::new(3200);
|
||||
let (mut messages, mut extra, text) = step(&mut k, true);
|
||||
assert_eq!(extra.len(), 2);
|
||||
k.observe(
|
||||
true,
|
||||
&stats(1020 + text + 300, 5),
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
);
|
||||
assert_eq!(extra.len(), 2);
|
||||
let r = &extra[1];
|
||||
assert!(r.img_relay && r.img_tokens == 300, "{r:?}");
|
||||
assert_eq!(
|
||||
r.content.as_ref().unwrap()[1]["image_url"]["url"],
|
||||
"loki-img:aa.png"
|
||||
);
|
||||
assert_eq!(messages[2].img_tokens, 300);
|
||||
assert_eq!(kept_image_tokens(&messages), 300);
|
||||
}
|
||||
|
||||
/// Au-delà du budget, l'image reste éphémère : retirée d'`extra`, gardée
|
||||
/// dans `messages` (le modèle l'a vue), et le tour s'arrête là.
|
||||
#[test]
|
||||
fn budget() {
|
||||
let mut k = KeepImages::new(200);
|
||||
let (mut messages, mut extra, text) = step(&mut k, true);
|
||||
k.observe(
|
||||
true,
|
||||
&stats(1020 + text + 300, 5),
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
);
|
||||
assert_eq!(extra.len(), 1);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert!(messages[2].img_relay && messages[2].img_tokens == 0);
|
||||
assert!(k.stopped);
|
||||
// Une image suivante n'est même pas rangée.
|
||||
assert!(!k.wants(&messages));
|
||||
}
|
||||
|
||||
/// Moteur sans usage, ou complétion coupée : rien à mesurer.
|
||||
#[test]
|
||||
fn sans_mesure() {
|
||||
for (complete, prompt) in [(true, 0), (false, 2000)] {
|
||||
let mut k = KeepImages::new(3200);
|
||||
let (mut messages, mut extra, _) = step(&mut k, true);
|
||||
k.observe(complete, &stats(prompt, 5), &mut messages, &mut extra);
|
||||
assert_eq!(extra.len(), 1, "complète={complete} prompt={prompt}");
|
||||
assert_eq!(kept_image_tokens(&messages), 0);
|
||||
}
|
||||
// Première complétion sans usage : pas de base.
|
||||
let mut k = KeepImages::new(3200);
|
||||
let mut messages = vec![um("q")];
|
||||
let mut extra = Vec::new();
|
||||
k.sending(1);
|
||||
k.observe(true, &stats(0, 0), &mut messages, &mut extra);
|
||||
let t = tm("[ok]");
|
||||
messages.push(t.clone());
|
||||
extra.push(t);
|
||||
k.relay(
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
img("data:x"),
|
||||
1,
|
||||
Some((img("loki-img:b.png"), 9)),
|
||||
);
|
||||
k.sending(messages.len());
|
||||
k.observe(true, &stats(5000, 5), &mut messages, &mut extra);
|
||||
assert_eq!(extra.len(), 1);
|
||||
}
|
||||
|
||||
/// Requête relancée entre-temps (reprise réseau) : la mesure ne vaut plus.
|
||||
#[test]
|
||||
fn requete_relancee() {
|
||||
let mut k = KeepImages::new(3200);
|
||||
let (mut messages, mut extra, text) = step(&mut k, true);
|
||||
k.sending(messages.len());
|
||||
k.observe(
|
||||
true,
|
||||
&stats(1020 + text + 300, 5),
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
);
|
||||
assert_eq!(extra.len(), 1);
|
||||
}
|
||||
|
||||
/// Rangement refusé : éphémère, marqué relais, et les suivantes aussi.
|
||||
#[test]
|
||||
fn rangement_refuse() {
|
||||
let mut k = KeepImages::new(3200);
|
||||
let (mut messages, extra, _) = step(&mut k, false);
|
||||
assert_eq!(extra.len(), 1);
|
||||
assert!(messages[2].img_relay);
|
||||
assert!(k.stopped);
|
||||
let mut extra = extra;
|
||||
k.relay(
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
img("data:y"),
|
||||
1,
|
||||
Some((img("loki-img:c.png"), 5)),
|
||||
);
|
||||
assert_eq!(extra.len(), 1, "tour arrêté : plus rien de gardé");
|
||||
assert!(messages[3].img_relay);
|
||||
}
|
||||
|
||||
/// Deux images dans l'étape : le coût réparti au prorata des octets, la
|
||||
/// dernière prend le reste ; une part sous le seuil, tout reste éphémère.
|
||||
#[test]
|
||||
fn partage_entre_images() {
|
||||
let mut k = KeepImages::new(3200);
|
||||
let mut messages = vec![um("q")];
|
||||
let mut extra = Vec::new();
|
||||
k.sending(1);
|
||||
k.observe(true, &stats(1000, 20), &mut messages, &mut extra);
|
||||
k.relay(
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
img("data:a"),
|
||||
1,
|
||||
Some((img("loki-img:a.png"), 3000)),
|
||||
);
|
||||
k.relay(
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
img("data:b"),
|
||||
1,
|
||||
Some((img("loki-img:b.png"), 1000)),
|
||||
);
|
||||
k.sending(messages.len());
|
||||
k.observe(true, &stats(1020 + 401, 5), &mut messages, &mut extra);
|
||||
assert_eq!(
|
||||
(extra[0].img_tokens, extra[1].img_tokens),
|
||||
(300, 101),
|
||||
"{extra:?}"
|
||||
);
|
||||
let mut k = KeepImages::new(3200);
|
||||
let mut messages = vec![um("q")];
|
||||
let mut extra = Vec::new();
|
||||
k.sending(1);
|
||||
k.observe(true, &stats(1000, 20), &mut messages, &mut extra);
|
||||
k.relay(
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
img("data:a"),
|
||||
1,
|
||||
Some((img("loki-img:a.png"), 3000)),
|
||||
);
|
||||
k.relay(
|
||||
&mut messages,
|
||||
&mut extra,
|
||||
img("data:b"),
|
||||
1,
|
||||
Some((img("loki-img:b.png"), 10)),
|
||||
);
|
||||
k.sending(messages.len());
|
||||
k.observe(true, &stats(1020 + 400, 5), &mut messages, &mut extra);
|
||||
assert!(extra.is_empty(), "part de 1 jeton : {extra:?}");
|
||||
}
|
||||
|
||||
/// Réécriture en cours de tour : une image non gardée n'entre pas dans
|
||||
/// l'historique publié ; gardée ou déjà retirée, si. Les images à l'essai ne
|
||||
/// sont plus suivies.
|
||||
#[test]
|
||||
fn publication() {
|
||||
let mut eph = img("data:image/png;base64,AA==");
|
||||
eph.img_relay = true;
|
||||
let mut stub = um(&format!("Image :{IMAGE_LOST_MARKER}"));
|
||||
stub.img_relay = true;
|
||||
let msgs = vec![um("q"), tm("a"), eph, tm("b"), kept(50), stub];
|
||||
let k = KeepImages::new(3200);
|
||||
let got = publishable(Some(&k), &msgs);
|
||||
assert_eq!(got.len(), 5);
|
||||
assert_eq!(got[2].content, Some(Value::from("b")));
|
||||
assert_eq!(publishable(None, &msgs).len(), 6, "sans la clé : tel quel");
|
||||
let mut k = KeepImages::new(3200);
|
||||
let (_, _, _) = step(&mut k, true);
|
||||
k.rewritten();
|
||||
assert!(k.stopped && k.pend.is_empty());
|
||||
let mut k = KeepImages::new(3200);
|
||||
k.rewritten();
|
||||
assert!(!k.stopped, "rien à l'essai : le tour continue");
|
||||
}
|
||||
|
||||
/// Fin du tour avec une image à l'essai : éphémère.
|
||||
#[test]
|
||||
fn fin_de_tour() {
|
||||
let mut k = KeepImages::new(3200);
|
||||
let (_, mut extra, _) = step(&mut k, true);
|
||||
k.finish(&mut extra);
|
||||
assert_eq!(extra.len(), 1);
|
||||
assert_eq!(extra[0].role, "tool");
|
||||
}
|
||||
|
||||
/// Budget déjà plein : rien à ranger.
|
||||
#[test]
|
||||
fn budget_plein_avant_rangement() {
|
||||
let k = KeepImages::new(200);
|
||||
assert!(!k.wants(&[um("q"), kept(200)]));
|
||||
assert!(k.wants(&[um("q"), kept(199)]));
|
||||
}
|
||||
@@ -28,6 +28,7 @@ pub mod generate;
|
||||
pub mod history;
|
||||
pub mod host;
|
||||
pub mod journal;
|
||||
pub mod keepimg;
|
||||
pub mod lease;
|
||||
pub mod nudges;
|
||||
pub mod perf;
|
||||
|
||||
+121
-13
@@ -34,9 +34,13 @@
|
||||
//! SLOT_PERSIST et le comptage des requêtes en vol passent par le Go (l'hôte,
|
||||
//! autour de [`TurnHost::engine_slot`] et de [`TurnHost::send`]).
|
||||
//!
|
||||
//! Images gardées (KEEP_TURN_IMAGES, lot 32) : une image relayée par un outil
|
||||
//! est gardée à l'essai jusqu'à la mesure de son coût par le moteur
|
||||
//! ([`super::keepimg`]) ; les images gardées partent avant tout résumé.
|
||||
//!
|
||||
//! Non portés (clés opt-in, ou machinerie du moteur local restée au Go) :
|
||||
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, préchauffage. Avec l'une de ces clés, le
|
||||
//! Go garde la conversation (`facadeChatBlocker`, décidé au démarrage).
|
||||
//! PROJ_SNAPSHOT, préchauffage. Avec l'une de ces clés, le Go garde la
|
||||
//! conversation (`facadeChatBlocker`, décidé au démarrage).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
@@ -51,13 +55,15 @@ use tokio::sync::watch;
|
||||
|
||||
use super::compact::cap_tool_result;
|
||||
use super::compact::{
|
||||
CompactConfig, compact_needed, compact_would_trigger, context_overflow, estimate_tokens,
|
||||
log_compact, log_ctx, log_ctx_estimate, overflow_tokens, shrink_to_fit,
|
||||
CompactConfig, compact_needed, compact_would_trigger, context_overflow, drop_kept_images,
|
||||
estimate_tokens, log_compact, log_ctx, log_ctx_estimate, log_kept_images_dropped,
|
||||
overflow_tokens, shrink_to_fit,
|
||||
};
|
||||
use super::engine::{
|
||||
LineError, Lines, NET_RETRIES, NetKind, net_delay, retryable, retryable_status,
|
||||
stream_cut_error,
|
||||
};
|
||||
use super::keepimg::{KeepImages, has_kept_images, publishable};
|
||||
use super::nudges::{
|
||||
MAX_NUDGES, THINK_NUDGE_FIRST, THINK_NUDGE_STUCK, append_nudge, budget_nudge, dedupable_tool,
|
||||
log_budget, repeated_call_result, steer_system, tool_result_preview,
|
||||
@@ -193,6 +199,17 @@ pub trait TurnHost: Send + Sync {
|
||||
fn preflight(&self) -> Option<PreflightFn> {
|
||||
None
|
||||
}
|
||||
/// KEEP_TURN_IMAGES : le budget des images gardées si la clé vaut pour
|
||||
/// ce tour (fil principal, vision active) ; `None` : images éphémères.
|
||||
fn keep_images(&self) -> impl Future<Output = Option<i64>> + Send {
|
||||
async { None }
|
||||
}
|
||||
/// Range par référence l'image d'un relais (`refRelayImage`) : le
|
||||
/// message qui la porte ainsi et son poids en octets ; `None` : elle
|
||||
/// reste éphémère.
|
||||
fn keep_image(&self, _m: &Message) -> impl Future<Output = Option<(Message, i64)>> + Send {
|
||||
async { None }
|
||||
}
|
||||
/// Slot du moteur où envoyer la requête qui part (`engineSlotFor`) ;
|
||||
/// `None` : aucun `id_slot`. Appelé avant chaque requête, moteur local ou
|
||||
/// non : l'hôte y recharge aussi l'état SLOT_PERSIST de la discussion.
|
||||
@@ -428,13 +445,46 @@ struct Loop {
|
||||
pool_retries: u32,
|
||||
}
|
||||
|
||||
/// `runChatLoop` : le tour, de la première requête à la réponse finale.
|
||||
/// `runChatTools` : le tour, de la première requête à la réponse finale.
|
||||
/// KEEP_TURN_IMAGES : 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.
|
||||
pub async fn run_turn<H: TurnHost>(
|
||||
host: &H,
|
||||
tc: &TurnConfig,
|
||||
messages: Vec<Message>,
|
||||
cancel: &Cancel,
|
||||
cb: &mut (dyn FnMut(StreamEvent) -> bool + Send),
|
||||
) -> TurnResult {
|
||||
let mut kt = host.keep_images().await.map(KeepImages::new);
|
||||
let mut r = run_turn_loop(host, tc, messages, cancel, cb, &mut kt).await;
|
||||
if let Some(k) = kt.as_mut() {
|
||||
k.finish(&mut r.extra);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// `publishHistory` : publie l'historique réécrit en cours de tour
|
||||
/// (compaction, réduction, retrait des images gardées). KEEP_TURN_IMAGES :
|
||||
/// une image non gardée n'y entre pas ; sans la clé, tel quel.
|
||||
fn publish_history(
|
||||
cb: &mut (dyn FnMut(StreamEvent) -> bool + Send),
|
||||
kt: &mut Option<KeepImages>,
|
||||
m: &[Message],
|
||||
) {
|
||||
if let Some(k) = kt.as_mut() {
|
||||
k.rewritten();
|
||||
}
|
||||
cb(StreamEvent::NewHistory(publishable(kt.as_ref(), m)));
|
||||
}
|
||||
|
||||
/// `runChatLoop` : la boucle de [`run_turn`].
|
||||
async fn run_turn_loop<H: TurnHost>(
|
||||
host: &H,
|
||||
tc: &TurnConfig,
|
||||
mut messages: Vec<Message>,
|
||||
cancel: &Cancel,
|
||||
cb: &mut (dyn FnMut(StreamEvent) -> bool + Send),
|
||||
kt: &mut Option<KeepImages>,
|
||||
) -> TurnResult {
|
||||
let mut extra: Vec<Message> = Vec::new();
|
||||
let mut s = Loop::default();
|
||||
@@ -451,9 +501,6 @@ pub async fn run_turn<H: TurnHost>(
|
||||
if tc.code {
|
||||
budget *= super::nudges::CODE_BUDGET_FACTOR;
|
||||
}
|
||||
let new_history = |cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), m: &[Message]| {
|
||||
cb(StreamEvent::NewHistory(m.to_vec()));
|
||||
};
|
||||
let fail =
|
||||
|cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), extra: Vec<Message>, err: String| {
|
||||
cb(StreamEvent::Err(err.clone()));
|
||||
@@ -524,6 +571,10 @@ pub async fn run_turn<H: TurnHost>(
|
||||
)
|
||||
};
|
||||
let t_req = Instant::now();
|
||||
// KEEP_TURN_IMAGES : rien sans la clé.
|
||||
if let Some(k) = kt.as_mut() {
|
||||
k.sending(messages.len());
|
||||
}
|
||||
let reply = match cancel.or_stop(host.send(ep, body)).await {
|
||||
None => return TurnResult { extra, err: None },
|
||||
Some(Ok(r)) => r,
|
||||
@@ -603,6 +654,18 @@ pub async fn run_turn<H: TurnHost>(
|
||||
if s.echo_off_turn {
|
||||
messages = strip_reasoning(&messages).into_owned();
|
||||
}
|
||||
// KEEP_TURN_IMAGES : les images gardées partent d'abord, et la
|
||||
// requête est rejouée sans elles ; le résumé (avec perte) ne vient
|
||||
// qu'ensuite, s'il le faut encore.
|
||||
if context_overflow(ccfg, &msg, &messages)
|
||||
&& let Some((c, freed)) = drop_kept_images(&messages)
|
||||
{
|
||||
log_kept_images_dropped("réactif", freed);
|
||||
messages = c;
|
||||
extra.clear();
|
||||
publish_history(cb, kt, &messages);
|
||||
continue;
|
||||
}
|
||||
let overflow = context_overflow(ccfg, &msg, &messages);
|
||||
if ccfg.enabled && !s.compacted_retry && overflow {
|
||||
let Some(c) = cancel
|
||||
@@ -616,7 +679,7 @@ pub async fn run_turn<H: TurnHost>(
|
||||
log_compact(ccfg, "réactif", 0, &messages, &c, true);
|
||||
messages = c;
|
||||
extra.clear();
|
||||
new_history(cb, &messages);
|
||||
publish_history(cb, kt, &messages);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -628,7 +691,7 @@ pub async fn run_turn<H: TurnHost>(
|
||||
log_compact(ccfg, "réduction", actual, &messages, &c, true);
|
||||
messages = c;
|
||||
extra.clear();
|
||||
new_history(cb, &messages);
|
||||
publish_history(cb, kt, &messages);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -761,6 +824,11 @@ pub async fn run_turn<H: TurnHost>(
|
||||
}
|
||||
s.last_est = 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.
|
||||
if let Some(k) = kt.as_mut() {
|
||||
k.observe(complete, &out.stats, &mut messages, &mut extra);
|
||||
}
|
||||
if out.finish_reason == "length" {
|
||||
log_ctx(&format!(
|
||||
"finish=length prompt={} généré={} raisonnement={} fenêtre={}",
|
||||
@@ -980,7 +1048,20 @@ pub async fn run_turn<H: TurnHost>(
|
||||
// Plafond pour le MODÈLE seulement : l'UI a reçu le résultat.
|
||||
push_result(&mut messages, &mut extra, cap_tool_result(&ex.result));
|
||||
if let Some(img) = ex.relay {
|
||||
messages.push(img); // éphémère : jamais dans l'historique
|
||||
match kt.as_mut() {
|
||||
// Éphémère : jamais dans l'historique.
|
||||
None => messages.push(img),
|
||||
// KEEP_TURN_IMAGES : gardée par référence, à l'essai
|
||||
// jusqu'à la mesure de son coût.
|
||||
Some(k) => {
|
||||
let stored = if k.wants(&messages) {
|
||||
host.keep_image(&img).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
k.relay(&mut messages, &mut extra, img, step_start, stored);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compaction EN COURS DE TOUR, sur le contexte réel du dernier appel
|
||||
@@ -991,6 +1072,21 @@ pub async fn run_turn<H: TurnHost>(
|
||||
used = base + estimate_tokens(&messages[step_start..], &ccfg.echo);
|
||||
s.last_est = used;
|
||||
}
|
||||
// KEEP_TURN_IMAGES : les images gardées partent AVANT tout résumé ;
|
||||
// la compaction ne suit que si elle reste nécessaire.
|
||||
if has_kept_images(&messages)
|
||||
&& compact_needed(ccfg, &messages, used, s.peak_gen)
|
||||
&& let Some((c, freed)) = drop_kept_images(&messages)
|
||||
{
|
||||
log_kept_images_dropped("en-tour", freed);
|
||||
messages = c;
|
||||
if used > 0 {
|
||||
used = (used - freed).max(1);
|
||||
s.last_est = used;
|
||||
}
|
||||
extra.clear();
|
||||
publish_history(cb, kt, &messages);
|
||||
}
|
||||
if compact_needed(ccfg, &messages, used, s.peak_gen)
|
||||
&& !host.compact_refused(used, &messages)
|
||||
{
|
||||
@@ -1016,7 +1112,7 @@ pub async fn run_turn<H: TurnHost>(
|
||||
s.last_est = 0;
|
||||
messages = c;
|
||||
extra.clear();
|
||||
new_history(cb, &messages);
|
||||
publish_history(cb, kt, &messages);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -1043,6 +1139,18 @@ pub async fn run_turn<H: TurnHost>(
|
||||
&& compact_would_trigger(ccfg, &messages, known)
|
||||
{
|
||||
s.length_replays += 1;
|
||||
// KEEP_TURN_IMAGES : les images gardées d'abord ; le résumé ne
|
||||
// suit que s'il reste nécessaire.
|
||||
if let Some((c, freed)) = drop_kept_images(&messages) {
|
||||
log_kept_images_dropped("fenêtre-pleine", freed);
|
||||
messages = c;
|
||||
extra.clear();
|
||||
publish_history(cb, kt, &messages);
|
||||
if !compact_would_trigger(ccfg, &messages, (known - freed).max(1)) {
|
||||
cb(StreamEvent::DropReasoning);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cb(StreamEvent::Compacting(true));
|
||||
let c = cancel
|
||||
.or_stop(host.compact(CompactPhase::FullWindow, &messages))
|
||||
@@ -1063,7 +1171,7 @@ pub async fn run_turn<H: TurnHost>(
|
||||
cb(StreamEvent::DropReasoning);
|
||||
messages = c;
|
||||
extra.clear();
|
||||
new_history(cb, &messages);
|
||||
publish_history(cb, kt, &messages);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ struct Fake {
|
||||
/// Slot rendu par l'hôte (SIDE_SLOT) ; demandes reçues.
|
||||
slot: Mutex<Option<u8>>,
|
||||
slot_asks: Mutex<usize>,
|
||||
/// KEEP_TURN_IMAGES : budget (clé valable pour le tour) ; image relayée
|
||||
/// par chaque outil ; rangements demandés (refusés si `keep_refuse`).
|
||||
keep: Mutex<Option<i64>>,
|
||||
relay: Mutex<Option<Message>>,
|
||||
keep_stores: Mutex<usize>,
|
||||
keep_refuse: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
@@ -120,6 +126,7 @@ impl TurnHost for Fake {
|
||||
.unwrap_or_else(|| format!("résultat de {name}"));
|
||||
ToolOutcome {
|
||||
result,
|
||||
relay: self.relay.lock().unwrap().clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -133,8 +140,8 @@ impl TurnHost for Fake {
|
||||
fn drain_queued(&self) -> Vec<Message> {
|
||||
std::mem::take(&mut *self.queued.lock().unwrap())
|
||||
}
|
||||
fn image_data_url(&self, _r: &str) -> Option<String> {
|
||||
None
|
||||
fn image_data_url(&self, r: &str) -> Option<String> {
|
||||
(r == "loki-img:aa.png").then(|| IMG_URL.to_string())
|
||||
}
|
||||
fn effort_remember(&self, want: &str, got: &str) {
|
||||
self.efforts.lock().unwrap().push((want.into(), got.into()));
|
||||
@@ -153,12 +160,83 @@ impl TurnHost for Fake {
|
||||
self.perfs.lock().unwrap().push(rec);
|
||||
self.marks.lock().unwrap().clone()
|
||||
}
|
||||
async fn keep_images(&self) -> Option<i64> {
|
||||
*self.keep.lock().unwrap()
|
||||
}
|
||||
async fn keep_image(&self, m: &Message) -> Option<(Message, i64)> {
|
||||
*self.keep_stores.lock().unwrap() += 1;
|
||||
if *self.keep_refuse.lock().unwrap() {
|
||||
return None;
|
||||
}
|
||||
let mut r = m.clone();
|
||||
r.content = Some(json!([
|
||||
m.content.as_ref().unwrap()[0].clone(),
|
||||
{"type": "image_url", "image_url": {"url": "loki-img:aa.png"}}
|
||||
]));
|
||||
Some((r, 1000))
|
||||
}
|
||||
async fn engine_slot(&self, _ep: &ChatEndpoint) -> Option<u8> {
|
||||
*self.slot_asks.lock().unwrap() += 1;
|
||||
*self.slot.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// L'image d'un relais : la même data-URL que rend la référence rangée.
|
||||
const IMG_URL: &str = "data:image/png;base64,AA==";
|
||||
|
||||
fn relay_msg() -> Message {
|
||||
Message {
|
||||
role: "user".into(),
|
||||
content: Some(json!([
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": {"url": IMG_URL}}
|
||||
])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fin de complétion avec cet usage ; `prompt` 0 : aucun usage.
|
||||
fn finish_u(reason: &str, prompt: i64, completion: i64) -> Vec<String> {
|
||||
let mut v = vec![format!(
|
||||
"data: {}",
|
||||
json!({"choices":[{"delta":{},"finish_reason": reason}]})
|
||||
)];
|
||||
if prompt > 0 {
|
||||
v.push(format!(
|
||||
"data: {}",
|
||||
json!({"choices":[],"usage":{"prompt_tokens":prompt,"completion_tokens":completion}})
|
||||
));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn see_image_step(prompt: i64) -> Script {
|
||||
let mut l = vec![call("v1", "see_image", r#"{"file":"img.png"}"#)];
|
||||
l.extend(finish_u("tool_calls", prompt, 20));
|
||||
Script::Ok(l)
|
||||
}
|
||||
|
||||
fn answer_u(s: &str, prompt: i64) -> Script {
|
||||
let mut l = vec![content(s)];
|
||||
l.extend(finish_u("stop", prompt, 5));
|
||||
Script::Ok(l)
|
||||
}
|
||||
|
||||
/// Lance un tour sur cet historique et rend (résultat, événements).
|
||||
async fn run_hist(
|
||||
host: &Fake,
|
||||
tc: &TurnConfig,
|
||||
hist: Vec<Message>,
|
||||
) -> (TurnResult, Vec<StreamEvent>) {
|
||||
let mut evs = Vec::new();
|
||||
let mut cb = |ev: StreamEvent| {
|
||||
evs.push(ev);
|
||||
true
|
||||
};
|
||||
let r = run_turn(host, tc, hist, &Cancel::never(), &mut cb).await;
|
||||
(r, evs)
|
||||
}
|
||||
|
||||
fn content(s: &str) -> String {
|
||||
format!("data: {}", json!({"choices":[{"delta":{"content": s}}]}))
|
||||
}
|
||||
@@ -797,3 +875,183 @@ async fn telemetrie_incomplete() {
|
||||
"pas de statistique finale"
|
||||
);
|
||||
}
|
||||
|
||||
// ── KEEP_TURN_IMAGES (lot 32, cas de chat_keep_images_test.go) ──
|
||||
|
||||
fn tool_res(s: &str) -> Message {
|
||||
Message {
|
||||
role: "tool".into(),
|
||||
tool_call_id: "v1".into(),
|
||||
content: Some(json!(s)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn kept_relay(tokens: i64) -> Message {
|
||||
let mut m = relay_msg();
|
||||
m.content.as_mut().unwrap()[1]["image_url"]["url"] = json!("loki-img:aa.png");
|
||||
m.img_relay = true;
|
||||
m.img_tokens = tokens;
|
||||
m
|
||||
}
|
||||
|
||||
/// Sans la clé : image éphémère, en data-URL, sans marque ; avec la clé, la
|
||||
/// requête qui la montre est la MÊME à l'octet près, et l'image est gardée
|
||||
/// par référence au coût mesuré (écart de prompt moins le texte ajouté).
|
||||
#[tokio::test]
|
||||
async fn images_gardees_requete_identique_et_mesuree() {
|
||||
let text = crate::chat::compact::msg_tokens(&tool_res("résultat de see_image"));
|
||||
let script = || vec![see_image_step(1000), answer_u("vu", 1020 + text + 300)];
|
||||
let off = Fake::new(script());
|
||||
*off.relay.lock().unwrap() = Some(relay_msg());
|
||||
let (r, _) = run(&off, &config(&["see_image"]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(r.extra.len(), 2, "sans la clé, l'image reste éphémère");
|
||||
assert!(r.extra.iter().all(|m| !m.img_relay && m.img_tokens == 0));
|
||||
assert_eq!(*off.keep_stores.lock().unwrap(), 0);
|
||||
|
||||
let on = Fake::new(script());
|
||||
*on.relay.lock().unwrap() = Some(relay_msg());
|
||||
*on.keep.lock().unwrap() = Some(3200);
|
||||
let (r, _) = run(&on, &config(&["see_image"]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(on.body(0), off.body(0));
|
||||
assert_eq!(on.body(1), off.body(1), "requête différente avec la clé");
|
||||
assert_eq!(r.extra.len(), 3, "{:?}", r.extra);
|
||||
let k = &r.extra[2];
|
||||
assert!(k.img_relay && k.img_tokens == 300, "{k:?}");
|
||||
assert_eq!(
|
||||
k.content.as_ref().unwrap()[1]["image_url"]["url"],
|
||||
"loki-img:aa.png"
|
||||
);
|
||||
}
|
||||
|
||||
/// Budget dépassé, moteur sans usage, rangement refusé : éphémère.
|
||||
#[tokio::test]
|
||||
async fn images_gardees_refusees() {
|
||||
let text = crate::chat::compact::msg_tokens(&tool_res("résultat de see_image"));
|
||||
for (budget, prompts, refuse) in [
|
||||
(200, (1000, 1020 + text + 300), false),
|
||||
(3200, (0, 0), false),
|
||||
(3200, (1000, 1020 + text + 300), true),
|
||||
] {
|
||||
let host = Fake::new(vec![see_image_step(prompts.0), answer_u("vu", prompts.1)]);
|
||||
*host.relay.lock().unwrap() = Some(relay_msg());
|
||||
*host.keep.lock().unwrap() = Some(budget);
|
||||
*host.keep_refuse.lock().unwrap() = refuse;
|
||||
let (r, _) = run(&host, &config(&["see_image"]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(
|
||||
r.extra.len(),
|
||||
2,
|
||||
"budget={budget} {prompts:?} refus={refuse}"
|
||||
);
|
||||
// Le modèle l'a vue quand même, en data-URL.
|
||||
assert_eq!(
|
||||
host.body(1)["messages"][3]["content"][1]["image_url"]["url"],
|
||||
IMG_URL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tour interrompu juste après l'image : jamais mesurée, éphémère.
|
||||
#[tokio::test]
|
||||
async fn image_a_l_essai_en_fin_de_tour() {
|
||||
let host = Fake::new(vec![
|
||||
see_image_step(1000),
|
||||
Script::Status(401, "clé refusée".into()),
|
||||
]);
|
||||
*host.relay.lock().unwrap() = Some(relay_msg());
|
||||
*host.keep.lock().unwrap() = Some(3200);
|
||||
let (r, _) = run(&host, &config(&["see_image"]), &Cancel::never()).await;
|
||||
assert!(r.err.is_some());
|
||||
assert_eq!(r.extra.len(), 2, "{:?}", r.extra);
|
||||
assert!(r.extra.iter().all(|m| !m.img_relay));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[tokio::test]
|
||||
async fn images_retirees_en_cours_de_tour() {
|
||||
let mut glob = vec![call("g1", "glob", r#"{"pattern":"*.go"}"#)];
|
||||
glob.extend(finish_u("tool_calls", 1600, 10));
|
||||
let host = Fake::new(vec![Script::Ok(glob), answer_u("fini", 0)]);
|
||||
let mut tc = config(&["glob"]);
|
||||
tc.compact.ctx_window = 2000;
|
||||
let hist = vec![
|
||||
um("q"),
|
||||
tool_res("[ok]"),
|
||||
kept_relay(1000),
|
||||
text_msg("assistant", "vu"),
|
||||
um("suite"),
|
||||
];
|
||||
let (r, evs) = run_hist(&host, &tc, hist.clone()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(host.sent(), 2);
|
||||
assert!(host.phases.lock().unwrap().is_empty(), "pas de résumé");
|
||||
let published = evs
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
StreamEvent::NewHistory(h) => Some(h.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("historique publié");
|
||||
assert_eq!(published.len(), hist.len() + 2);
|
||||
assert_eq!(
|
||||
published[2].content,
|
||||
Some(json!(format!(
|
||||
"Voici la capture demandée.{}",
|
||||
crate::chat::compact::IMAGE_LOST_MARKER
|
||||
)))
|
||||
);
|
||||
assert_eq!(
|
||||
host.body(1)["messages"][2]["content"],
|
||||
published[2].content.clone().unwrap()
|
||||
);
|
||||
assert!(r.extra.is_empty(), "{:?}", r.extra);
|
||||
}
|
||||
|
||||
/// Prompt refusé pour débordement : les images gardées partent d'abord, la
|
||||
/// requête est rejouée sans elles, sans résumé.
|
||||
#[tokio::test]
|
||||
async fn images_retirees_avant_le_filet_reactif() {
|
||||
let host = Fake::new(vec![
|
||||
Script::Status(
|
||||
400,
|
||||
r#"{"error":{"code":400,"message":"request (2500 tokens) exceeds the available context size (2000 tokens), try increasing it"}}"#.into(),
|
||||
),
|
||||
answer_u("fini", 0),
|
||||
]);
|
||||
*host.compacted.lock().unwrap() = Some(vec![um("résumé")]);
|
||||
let mut tc = config(&[]);
|
||||
tc.compact.ctx_window = 2000;
|
||||
let hist = vec![um("q"), tool_res("[ok]"), kept_relay(1000), um("suite")];
|
||||
let (r, evs) = run_hist(&host, &tc, hist.clone()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(host.sent(), 2);
|
||||
assert!(host.phases.lock().unwrap().is_empty(), "pas de résumé");
|
||||
assert!(evs.iter().any(
|
||||
|e| matches!(e, StreamEvent::NewHistory(h) if h.len() == hist.len() && h[2].img_tokens == 0)
|
||||
));
|
||||
assert!(host.body(1)["messages"][2]["content"].is_string());
|
||||
}
|
||||
|
||||
/// Coupée par une fenêtre pleine : les images gardées d'abord ; leur retrait
|
||||
/// suffit, l'étape est rejouée sans résumé.
|
||||
#[tokio::test]
|
||||
async fn images_retirees_fenetre_pleine() {
|
||||
let mut cut = vec![];
|
||||
cut.extend(finish_u("length", 1500, 200));
|
||||
let host = Fake::new(vec![Script::Ok(cut), answer_u("fini", 0)]);
|
||||
*host.compacted.lock().unwrap() = Some(vec![um("résumé")]);
|
||||
let mut tc = config(&[]);
|
||||
tc.compact.ctx_window = 2000;
|
||||
let hist = vec![um("q"), tool_res("[ok]"), kept_relay(1000), um("suite")];
|
||||
let (r, evs) = run_hist(&host, &tc, hist).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(host.sent(), 2);
|
||||
assert!(host.phases.lock().unwrap().is_empty(), "pas de résumé");
|
||||
assert!(evs.iter().any(|e| matches!(e, StreamEvent::DropReasoning)));
|
||||
assert_eq!(text(&evs), "fini");
|
||||
}
|
||||
@@ -132,6 +132,11 @@ pub struct FakeGo {
|
||||
/// reçues (« conv main »).
|
||||
pub turn_side_live: Mutex<bool>,
|
||||
pub turn_slots: Mutex<Vec<String>>,
|
||||
/// Message relayé par chaque outil exécuté ici (image d'une capture).
|
||||
pub tool_relay: Mutex<Option<serde_json::Value>>,
|
||||
/// /internal/v1/turn/keepimg : vision active ; rangements reçus.
|
||||
pub keep_vision: Mutex<bool>,
|
||||
pub keep_stores: Mutex<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
impl FakeGo {
|
||||
@@ -648,11 +653,14 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
let result = by_name
|
||||
.or_else(|| st.tool_result.lock().unwrap().clone())
|
||||
.unwrap_or_else(|| format!("exec:{}", v["name"].as_str().unwrap()));
|
||||
let out = serde_json::json!({
|
||||
let mut out = serde_json::json!({
|
||||
"label": v["args"]["command"],
|
||||
"result": result,
|
||||
"caps_agent": v["caps"]["Agent"],
|
||||
});
|
||||
if let Some(r) = st.tool_relay.lock().unwrap().clone() {
|
||||
out["relay"] = r;
|
||||
}
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/oai/route" => {
|
||||
@@ -687,6 +695,24 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
}
|
||||
resp(200, r#"{"ticket":7}"#)
|
||||
}
|
||||
"/internal/v1/turn/keepimg" => {
|
||||
let v = serde_json::json!({
|
||||
"vision": *st.keep_vision.lock().unwrap(),
|
||||
"budget": 3276,
|
||||
});
|
||||
resp(200, v.to_string())
|
||||
}
|
||||
"/internal/v1/turn/keepimg/store" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let mut msg = v["msg"].clone();
|
||||
st.keep_stores.lock().unwrap().push(msg.clone());
|
||||
msg["content"][1]["image_url"]["url"] = serde_json::json!("loki-img:k1.png");
|
||||
resp(
|
||||
200,
|
||||
serde_json::json!({ "ok": true, "msg": msg, "weight": 4 }).to_string(),
|
||||
)
|
||||
}
|
||||
"/internal/v1/turn/slot" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
@@ -1992,3 +1992,93 @@ async fn telemetrie_des_resumes() {
|
||||
);
|
||||
assert_eq!(perfs[1]["complete"], false);
|
||||
}
|
||||
|
||||
/// Lot 32 : KEEP_TURN_IMAGES tenu par la façade. L'image relayée par un
|
||||
/// outil est rangée par le Go, gardée au coût mesuré par le moteur ; au tour
|
||||
/// suivant, clé retirée, elle part d'abord (légende et marque gardées).
|
||||
#[tokio::test]
|
||||
async fn images_gardees_par_la_facade() {
|
||||
use loki_rs::chat::compact::{IMAGE_LOST_MARKER, msg_tokens};
|
||||
use loki_rs::chat::types::Message;
|
||||
let tool = Message {
|
||||
role: "tool".into(),
|
||||
tool_call_id: "c1".into(),
|
||||
content: Some(serde_json::json!("exec:bash")),
|
||||
..Default::default()
|
||||
};
|
||||
let prompt2 = 1020 + msg_tokens(&tool) + 300;
|
||||
let (url, seen) = fake_engine(vec![
|
||||
sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"bash","arguments":"{\"command\":\"ls\"}"}}]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1000,"completion_tokens":20}}),
|
||||
]),
|
||||
sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"content": "vu"}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":prompt2,"completion_tokens":5}}),
|
||||
]),
|
||||
answer("ensuite"),
|
||||
])
|
||||
.await;
|
||||
let (svc, go, _stop, t) = service(Some(&url)).await;
|
||||
// Le fichier que le Go range (ici, le faux ne l'écrit pas).
|
||||
std::fs::create_dir_all(t.path().join("chatimg")).unwrap();
|
||||
std::fs::write(t.path().join("chatimg/k1.png"), b"\x89PNG\r\n").unwrap();
|
||||
go.set("config", "KEEP_TURN_IMAGES", "on");
|
||||
*go.keep_vision.lock().unwrap() = true;
|
||||
*go.tool_relay.lock().unwrap() = Some(serde_json::json!({
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0K"}}
|
||||
]
|
||||
}));
|
||||
svc.send(serde_json::json!({"message": "capture", "cid": "k1"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
until(|| go.turn_log.lock().unwrap().contains(&"done".to_string())).await;
|
||||
// Rangée par le Go, vue en data-URL par le moteur.
|
||||
assert_eq!(go.keep_stores.lock().unwrap().len(), 1);
|
||||
let sent = seen.lock().unwrap()[1].1.clone();
|
||||
let relay = &sent["messages"].as_array().unwrap()[4];
|
||||
assert_eq!(
|
||||
relay["content"][1]["image_url"]["url"],
|
||||
"data:image/png;base64,iVBORw0K"
|
||||
);
|
||||
assert!(relay.get("img_relay").is_none(), "marque envoyée au moteur");
|
||||
let msgs = svc.sessions.conv().with_state(|st| st.messages.clone());
|
||||
let kept = msgs.iter().find(|m| m.img_relay).expect("relais gardé");
|
||||
assert_eq!(kept.img_tokens, 300);
|
||||
assert_eq!(
|
||||
kept.content.as_ref().unwrap()[1]["image_url"]["url"],
|
||||
"loki-img:k1.png"
|
||||
);
|
||||
|
||||
// Clé retirée : l'image gardée part au début du tour suivant.
|
||||
go.set("config", "KEEP_TURN_IMAGES", "off");
|
||||
svc.send(serde_json::json!({"message": "et ensuite ?", "cid": "k2"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 2).await;
|
||||
let msgs = svc.sessions.conv().with_state(|st| st.messages.clone());
|
||||
let stub = msgs
|
||||
.iter()
|
||||
.find(|m| m.img_relay)
|
||||
.expect("relais gardé en légende");
|
||||
assert_eq!(stub.img_tokens, 0);
|
||||
assert_eq!(
|
||||
stub.content,
|
||||
Some(serde_json::json!(format!(
|
||||
"Voici la capture demandée.{IMAGE_LOST_MARKER}"
|
||||
)))
|
||||
);
|
||||
let sent = seen.lock().unwrap()[2].1.clone();
|
||||
assert!(
|
||||
sent["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|m| !m["content"].to_string().contains("image_url")),
|
||||
"image encore envoyée : {sent}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user