mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 23:18:13 +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_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_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_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_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_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 |
|
| `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
|
// 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
|
// 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) :
|
// figé, préchauffage). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa
|
||||||
// la façade a sa sonde de gabarit ; ni les motifs de relance personnalisés
|
// sonde de gabarit ; ni les motifs de relance personnalisés (retry_patterns,
|
||||||
// (retry_patterns, lot 30) : la façade les lit et les compile ; ni SIDE_SLOT et
|
// lot 30) : la façade les lit et les compile ; ni SIDE_SLOT et SLOT_PERSIST
|
||||||
// SLOT_PERSIST (lot 31) : la façade demande son slot avant chaque requête
|
// (lot 31) : la façade demande son slot avant chaque requête
|
||||||
// (facade_slots.go).
|
// (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 {
|
func facadeChatBlocker(cfg map[string]string) string {
|
||||||
switch {
|
switch {
|
||||||
case projSnapEnabled(cfg):
|
case projSnapEnabled(cfg):
|
||||||
return "PROJ_SNAPSHOT"
|
return "PROJ_SNAPSHOT"
|
||||||
case keepTurnImagesOn(cfg):
|
|
||||||
return "KEEP_TURN_IMAGES"
|
|
||||||
case prewarmMode(cfg) != "":
|
case prewarmMode(cfg) != "":
|
||||||
return "PREWARM"
|
return "PREWARM"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
|
|||||||
t.Fatalf("détenteur : %d %s", w.Code, w.Body.String())
|
t.Fatalf("détenteur : %d %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
// Posé après la décision : sans effet avant le redémarrage.
|
// 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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !facadeChat() {
|
if !facadeChat() {
|
||||||
@@ -285,10 +285,10 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
oublierDecisionFacade(t)
|
oublierDecisionFacade(t)
|
||||||
if facadeChat() {
|
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", "")
|
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())
|
t.Fatalf("détenteur : %s", w.Body.String())
|
||||||
}
|
}
|
||||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
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{
|
for k, want := range map[string]string{
|
||||||
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "",
|
"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 {
|
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
|
||||||
t.Errorf("%s : %q", k, got)
|
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)
|
handleInternalTurnPerf(w, r)
|
||||||
case "/internal/v1/turn/slot":
|
case "/internal/v1/turn/slot":
|
||||||
handleInternalTurnSlot(w, r)
|
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":
|
case "/internal/v1/tool/exec":
|
||||||
handleInternalToolExec(w, r)
|
handleInternalToolExec(w, r)
|
||||||
case "/internal/v1/tool/context":
|
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
|
/// `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.
|
/// 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) {
|
if !msgs.iter().any(|m| m.img_tokens > 0) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -441,7 +441,7 @@ fn drop_kept_images(msgs: &[Message]) -> Option<(Vec<Message>, i64)> {
|
|||||||
Some((out, freed))
|
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)");
|
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
|
//! corrections du builder) suit le tour, avant la compaction de fin
|
||||||
//! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé.
|
//! ([`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
|
//! Écarts assumés (restent au Go) : PROJ_SNAPSHOT (refusé par
|
||||||
//! (refusés par `/turn/view`), préchauffage. La télémétrie des complétions
|
//! `/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
|
//! (tours, vérifications, résumés) rejoint l'anneau du Go
|
||||||
//! (`/internal/v1/turn/perf`) ; les motifs `retry_patterns` personnalisés
|
//! (`/internal/v1/turn/perf`) ; les motifs `retry_patterns` personnalisés
|
||||||
//! sont lus au démarrage ([`retry_patterns`]). Chaque requête au moteur local
|
//! 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,
|
//! part sur le slot que rend le Go (SIDE_SLOT, rechargement SLOT_PERSIST,
|
||||||
//! lot 31).
|
//! lot 31).
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -42,14 +44,18 @@ use tokio::sync::watch;
|
|||||||
|
|
||||||
use super::compact::{
|
use super::compact::{
|
||||||
CompactCaps, CompactConfig, CompactOpts, Compactor, TurnView, compact_needed, compact_opts_for,
|
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::engine::EngineHttp;
|
||||||
use super::history::{
|
use super::history::{
|
||||||
drop_stray_nudges, history_from_view, inject_prefix, remind_read_mem_pages, tool_used_delta,
|
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::journal::Delta;
|
||||||
|
use super::keepimg::{has_kept_images, keep_turn_images_on};
|
||||||
use super::lease::{EngineLease, LEASE_TTL, LeaseError, LeaseSignal, engine_stop};
|
use super::lease::{EngineLease, LEASE_TTL, LeaseError, LeaseSignal, engine_stop};
|
||||||
use super::sessions::{GoSessionHooks, Sessions};
|
use super::sessions::{GoSessionHooks, Sessions};
|
||||||
use super::turn::{Cancel, TurnConfig, run_turn};
|
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,
|
/// `compactAndPublish` : bannière, journal, installation du résultat,
|
||||||
/// jauge de contexte. Rend l'historique compacté, `None` s'il n'a pas
|
/// jauge de contexte. Rend l'historique compacté, `None` s'il n'a pas
|
||||||
/// changé.
|
/// changé.
|
||||||
@@ -718,10 +773,15 @@ impl ChatService {
|
|||||||
let peak = std::mem::take(&mut st.gen_peak);
|
let peak = std::mem::take(&mut st.gen_peak);
|
||||||
(msgs, used, peak)
|
(msgs, used, peak)
|
||||||
});
|
});
|
||||||
let Some((mut msgs, ctx_used, peak)) = snap else {
|
let Some((msgs, ctx_used, peak)) = snap else {
|
||||||
lease.release().await;
|
lease.release().await;
|
||||||
return;
|
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.
|
// Compaction de début de tour.
|
||||||
let mut start_est = ctx_used;
|
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
|
// 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()
|
if !cancel.is_cancelled()
|
||||||
&& compact_needed(&ccfg, &msgs, ctx_used, peak)
|
&& compact_needed(&ccfg, &msgs, ctx_used, peak)
|
||||||
&& !self
|
&& !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
|
/// Rend le ticket « en vol » au Go quand la réponse du moteur est lue ou
|
||||||
/// abandonnée (`engineRequestBeginSide` … `end`).
|
/// abandonnée (`engineRequestBeginSide` … `end`).
|
||||||
pub struct GateTicket {
|
pub struct GateTicket {
|
||||||
@@ -704,6 +720,35 @@ impl TurnHost for GoTurnHost {
|
|||||||
self.shared.echo_refusals.refuse(model);
|
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> {
|
async fn engine_slot(&self, ep: &ChatEndpoint) -> Option<u8> {
|
||||||
if ep.external {
|
if ep.external {
|
||||||
self.side_slot.store(false, Ordering::SeqCst);
|
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 history;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
|
pub mod keepimg;
|
||||||
pub mod lease;
|
pub mod lease;
|
||||||
pub mod nudges;
|
pub mod nudges;
|
||||||
pub mod perf;
|
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,
|
//! 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`]).
|
//! 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) :
|
//! 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
|
//! PROJ_SNAPSHOT, préchauffage. Avec l'une de ces clés, le Go garde la
|
||||||
//! Go garde la conversation (`facadeChatBlocker`, décidé au démarrage).
|
//! conversation (`facadeChatBlocker`, décidé au démarrage).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -51,13 +55,15 @@ use tokio::sync::watch;
|
|||||||
|
|
||||||
use super::compact::cap_tool_result;
|
use super::compact::cap_tool_result;
|
||||||
use super::compact::{
|
use super::compact::{
|
||||||
CompactConfig, compact_needed, compact_would_trigger, context_overflow, estimate_tokens,
|
CompactConfig, compact_needed, compact_would_trigger, context_overflow, drop_kept_images,
|
||||||
log_compact, log_ctx, log_ctx_estimate, overflow_tokens, shrink_to_fit,
|
estimate_tokens, log_compact, log_ctx, log_ctx_estimate, log_kept_images_dropped,
|
||||||
|
overflow_tokens, shrink_to_fit,
|
||||||
};
|
};
|
||||||
use super::engine::{
|
use super::engine::{
|
||||||
LineError, Lines, NET_RETRIES, NetKind, net_delay, retryable, retryable_status,
|
LineError, Lines, NET_RETRIES, NetKind, net_delay, retryable, retryable_status,
|
||||||
stream_cut_error,
|
stream_cut_error,
|
||||||
};
|
};
|
||||||
|
use super::keepimg::{KeepImages, has_kept_images, publishable};
|
||||||
use super::nudges::{
|
use super::nudges::{
|
||||||
MAX_NUDGES, THINK_NUDGE_FIRST, THINK_NUDGE_STUCK, append_nudge, budget_nudge, dedupable_tool,
|
MAX_NUDGES, THINK_NUDGE_FIRST, THINK_NUDGE_STUCK, append_nudge, budget_nudge, dedupable_tool,
|
||||||
log_budget, repeated_call_result, steer_system, tool_result_preview,
|
log_budget, repeated_call_result, steer_system, tool_result_preview,
|
||||||
@@ -193,6 +199,17 @@ pub trait TurnHost: Send + Sync {
|
|||||||
fn preflight(&self) -> Option<PreflightFn> {
|
fn preflight(&self) -> Option<PreflightFn> {
|
||||||
None
|
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`) ;
|
/// Slot du moteur où envoyer la requête qui part (`engineSlotFor`) ;
|
||||||
/// `None` : aucun `id_slot`. Appelé avant chaque requête, moteur local ou
|
/// `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.
|
/// non : l'hôte y recharge aussi l'état SLOT_PERSIST de la discussion.
|
||||||
@@ -428,13 +445,46 @@ struct Loop {
|
|||||||
pool_retries: u32,
|
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>(
|
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,
|
host: &H,
|
||||||
tc: &TurnConfig,
|
tc: &TurnConfig,
|
||||||
mut messages: Vec<Message>,
|
mut messages: Vec<Message>,
|
||||||
cancel: &Cancel,
|
cancel: &Cancel,
|
||||||
cb: &mut (dyn FnMut(StreamEvent) -> bool + Send),
|
cb: &mut (dyn FnMut(StreamEvent) -> bool + Send),
|
||||||
|
kt: &mut Option<KeepImages>,
|
||||||
) -> TurnResult {
|
) -> TurnResult {
|
||||||
let mut extra: Vec<Message> = Vec::new();
|
let mut extra: Vec<Message> = Vec::new();
|
||||||
let mut s = Loop::default();
|
let mut s = Loop::default();
|
||||||
@@ -451,9 +501,6 @@ pub async fn run_turn<H: TurnHost>(
|
|||||||
if tc.code {
|
if tc.code {
|
||||||
budget *= super::nudges::CODE_BUDGET_FACTOR;
|
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 =
|
let fail =
|
||||||
|cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), extra: Vec<Message>, err: String| {
|
|cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), extra: Vec<Message>, err: String| {
|
||||||
cb(StreamEvent::Err(err.clone()));
|
cb(StreamEvent::Err(err.clone()));
|
||||||
@@ -524,6 +571,10 @@ pub async fn run_turn<H: TurnHost>(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
let t_req = Instant::now();
|
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 {
|
let reply = match cancel.or_stop(host.send(ep, body)).await {
|
||||||
None => return TurnResult { extra, err: None },
|
None => return TurnResult { extra, err: None },
|
||||||
Some(Ok(r)) => r,
|
Some(Ok(r)) => r,
|
||||||
@@ -603,6 +654,18 @@ pub async fn run_turn<H: TurnHost>(
|
|||||||
if s.echo_off_turn {
|
if s.echo_off_turn {
|
||||||
messages = strip_reasoning(&messages).into_owned();
|
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);
|
let overflow = context_overflow(ccfg, &msg, &messages);
|
||||||
if ccfg.enabled && !s.compacted_retry && overflow {
|
if ccfg.enabled && !s.compacted_retry && overflow {
|
||||||
let Some(c) = cancel
|
let Some(c) = cancel
|
||||||
@@ -616,7 +679,7 @@ pub async fn run_turn<H: TurnHost>(
|
|||||||
log_compact(ccfg, "réactif", 0, &messages, &c, true);
|
log_compact(ccfg, "réactif", 0, &messages, &c, true);
|
||||||
messages = c;
|
messages = c;
|
||||||
extra.clear();
|
extra.clear();
|
||||||
new_history(cb, &messages);
|
publish_history(cb, kt, &messages);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,7 +691,7 @@ pub async fn run_turn<H: TurnHost>(
|
|||||||
log_compact(ccfg, "réduction", actual, &messages, &c, true);
|
log_compact(ccfg, "réduction", actual, &messages, &c, true);
|
||||||
messages = c;
|
messages = c;
|
||||||
extra.clear();
|
extra.clear();
|
||||||
new_history(cb, &messages);
|
publish_history(cb, kt, &messages);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,6 +824,11 @@ pub async fn run_turn<H: TurnHost>(
|
|||||||
}
|
}
|
||||||
s.last_est = 0;
|
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" {
|
if out.finish_reason == "length" {
|
||||||
log_ctx(&format!(
|
log_ctx(&format!(
|
||||||
"finish=length prompt={} généré={} raisonnement={} fenêtre={}",
|
"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.
|
// 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));
|
push_result(&mut messages, &mut extra, cap_tool_result(&ex.result));
|
||||||
if let Some(img) = ex.relay {
|
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
|
// 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);
|
used = base + estimate_tokens(&messages[step_start..], &ccfg.echo);
|
||||||
s.last_est = used;
|
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)
|
if compact_needed(ccfg, &messages, used, s.peak_gen)
|
||||||
&& !host.compact_refused(used, &messages)
|
&& !host.compact_refused(used, &messages)
|
||||||
{
|
{
|
||||||
@@ -1016,7 +1112,7 @@ pub async fn run_turn<H: TurnHost>(
|
|||||||
s.last_est = 0;
|
s.last_est = 0;
|
||||||
messages = c;
|
messages = c;
|
||||||
extra.clear();
|
extra.clear();
|
||||||
new_history(cb, &messages);
|
publish_history(cb, kt, &messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -1043,6 +1139,18 @@ pub async fn run_turn<H: TurnHost>(
|
|||||||
&& compact_would_trigger(ccfg, &messages, known)
|
&& compact_would_trigger(ccfg, &messages, known)
|
||||||
{
|
{
|
||||||
s.length_replays += 1;
|
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));
|
cb(StreamEvent::Compacting(true));
|
||||||
let c = cancel
|
let c = cancel
|
||||||
.or_stop(host.compact(CompactPhase::FullWindow, &messages))
|
.or_stop(host.compact(CompactPhase::FullWindow, &messages))
|
||||||
@@ -1063,7 +1171,7 @@ pub async fn run_turn<H: TurnHost>(
|
|||||||
cb(StreamEvent::DropReasoning);
|
cb(StreamEvent::DropReasoning);
|
||||||
messages = c;
|
messages = c;
|
||||||
extra.clear();
|
extra.clear();
|
||||||
new_history(cb, &messages);
|
publish_history(cb, kt, &messages);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ struct Fake {
|
|||||||
/// Slot rendu par l'hôte (SIDE_SLOT) ; demandes reçues.
|
/// Slot rendu par l'hôte (SIDE_SLOT) ; demandes reçues.
|
||||||
slot: Mutex<Option<u8>>,
|
slot: Mutex<Option<u8>>,
|
||||||
slot_asks: Mutex<usize>,
|
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 {
|
impl Fake {
|
||||||
@@ -120,6 +126,7 @@ impl TurnHost for Fake {
|
|||||||
.unwrap_or_else(|| format!("résultat de {name}"));
|
.unwrap_or_else(|| format!("résultat de {name}"));
|
||||||
ToolOutcome {
|
ToolOutcome {
|
||||||
result,
|
result,
|
||||||
|
relay: self.relay.lock().unwrap().clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,8 +140,8 @@ impl TurnHost for Fake {
|
|||||||
fn drain_queued(&self) -> Vec<Message> {
|
fn drain_queued(&self) -> Vec<Message> {
|
||||||
std::mem::take(&mut *self.queued.lock().unwrap())
|
std::mem::take(&mut *self.queued.lock().unwrap())
|
||||||
}
|
}
|
||||||
fn image_data_url(&self, _r: &str) -> Option<String> {
|
fn image_data_url(&self, r: &str) -> Option<String> {
|
||||||
None
|
(r == "loki-img:aa.png").then(|| IMG_URL.to_string())
|
||||||
}
|
}
|
||||||
fn effort_remember(&self, want: &str, got: &str) {
|
fn effort_remember(&self, want: &str, got: &str) {
|
||||||
self.efforts.lock().unwrap().push((want.into(), got.into()));
|
self.efforts.lock().unwrap().push((want.into(), got.into()));
|
||||||
@@ -153,12 +160,83 @@ impl TurnHost for Fake {
|
|||||||
self.perfs.lock().unwrap().push(rec);
|
self.perfs.lock().unwrap().push(rec);
|
||||||
self.marks.lock().unwrap().clone()
|
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> {
|
async fn engine_slot(&self, _ep: &ChatEndpoint) -> Option<u8> {
|
||||||
*self.slot_asks.lock().unwrap() += 1;
|
*self.slot_asks.lock().unwrap() += 1;
|
||||||
*self.slot.lock().unwrap()
|
*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 {
|
fn content(s: &str) -> String {
|
||||||
format!("data: {}", json!({"choices":[{"delta":{"content": s}}]}))
|
format!("data: {}", json!({"choices":[{"delta":{"content": s}}]}))
|
||||||
}
|
}
|
||||||
@@ -797,3 +875,183 @@ async fn telemetrie_incomplete() {
|
|||||||
"pas de statistique finale"
|
"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 »).
|
/// reçues (« conv main »).
|
||||||
pub turn_side_live: Mutex<bool>,
|
pub turn_side_live: Mutex<bool>,
|
||||||
pub turn_slots: Mutex<Vec<String>>,
|
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 {
|
impl FakeGo {
|
||||||
@@ -648,11 +653,14 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
|||||||
let result = by_name
|
let result = by_name
|
||||||
.or_else(|| st.tool_result.lock().unwrap().clone())
|
.or_else(|| st.tool_result.lock().unwrap().clone())
|
||||||
.unwrap_or_else(|| format!("exec:{}", v["name"].as_str().unwrap()));
|
.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"],
|
"label": v["args"]["command"],
|
||||||
"result": result,
|
"result": result,
|
||||||
"caps_agent": v["caps"]["Agent"],
|
"caps_agent": v["caps"]["Agent"],
|
||||||
});
|
});
|
||||||
|
if let Some(r) = st.tool_relay.lock().unwrap().clone() {
|
||||||
|
out["relay"] = r;
|
||||||
|
}
|
||||||
resp(200, out.to_string())
|
resp(200, out.to_string())
|
||||||
}
|
}
|
||||||
"/internal/v1/oai/route" => {
|
"/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}"#)
|
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" => {
|
"/internal/v1/turn/slot" => {
|
||||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
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);
|
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