mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Lot 34 : bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
PROJ_SNAPSHOT était le dernier réglage qui gardait la conversation au Go : la façade la tient désormais dans tous les cas (LOKI_CHAT=go reste la porte de sortie). facadeChatBlocker et la décision prise une fois par processus disparaissent. Go (facade_projsnap.go) : - /internal/v1/turn/view, avec la clé et l'agent : tête du tour portant le bloc figé (ou le vivant à rafraîchir), préambule sans date ni dossier, et la décision projsnap (rafraîchir, mise à jour à poser, instantané à ranger, clé et système du tour) ; dry pour le préchauffage ; - /internal/v1/turn/projsnap/refresh : bloc vivant et instantané qu'il devient, pour une compaction en plein tour. Rust : - ChatService::assemble_turn, partagé par le tour et la relance du builder : instantané rangé avec la discussion, mise à jour posée en tête du dernier message utilisateur (prependCtxUpdate), historique nettoyé au rafraîchissement ; - TurnHost::project_refresh après une compaction ou une réduction en plein tour (replaceProjectHead) ; l'instantané posé devient celui de la discussion ; - le préchauffage demande la vue en dry et garde les mises à jour tant que le bloc n'est pas rafraîchi. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
19 files changed
+1063
-171
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) les images gardées (`KEEP_TURN_IMAGES`) et le préchauffage (`PREWARM`). `go` : la rend au Go (porte de sortie). Avec `PROJ_SNAPSHOT`, 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) les images gardées (`KEEP_TURN_IMAGES`), le préchauffage (`PREWARM`) et le bloc projet figé (`PROJ_SNAPSHOT`). `go` : la rend au Go (porte de sortie) | 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,92 @@
|
||||
# Lot 34 — Bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le contexte du projet part en tête du premier message utilisateur :
|
||||
|
||||
- description ;
|
||||
- index mémoire ;
|
||||
- trackers ;
|
||||
- consignes du dépôt ;
|
||||
- avec la clé, la date et le dossier de travail.
|
||||
|
||||
Reconstruit à chaque tour, il change dès qu'une page est créée ou qu'un
|
||||
tracker reçoit un point. Tout ce qui le suit dans le prompt est alors
|
||||
recalculé.
|
||||
|
||||
`PROJ_SNAPSHOT` (`chat_projsnap.go`) garde une copie figée du bloc par
|
||||
discussion. Les changements arrivent en `<context_update>` en tête du
|
||||
nouveau message utilisateur, rangés tels quels dans l'historique. Le bloc
|
||||
n'est rafraîchi que quand le début du prompt change de toute façon :
|
||||
compaction, système ou outils, clé du moteur, projet, mode, trop de mises à
|
||||
jour accumulées.
|
||||
|
||||
C'était le dernier réglage qui gardait la conversation au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
L'instantané est déjà persisté avec la discussion (`ConvState::proj_snap`),
|
||||
et la compaction le remet à zéro comme le Go. Il manquait la décision, qui
|
||||
lit le contexte du projet : elle reste au Go.
|
||||
|
||||
### Go (`facade_projsnap.go`)
|
||||
|
||||
- `/internal/v1/turn/view`, avec la clé et l'agent :
|
||||
- reçoit l'instantané de la discussion (`snap`) ;
|
||||
- rend la tête du tour (prompt du preset, puis le bloc figé, ou le vivant
|
||||
s'il faut rafraîchir) et le préambule sans date ni dossier
|
||||
(`envInCtx`) ;
|
||||
- rend aussi `projsnap` : rafraîchir ou non, la mise à jour à poser,
|
||||
l'instantané à ranger, la clé et l'empreinte du système du tour.
|
||||
- `dry` (préchauffage) : la même décision, rien à ranger.
|
||||
- `/internal/v1/turn/projsnap/refresh` : le bloc vivant et l'instantané qu'il
|
||||
devient (`projSnapTurn.refresh`).
|
||||
- Plus aucun réglage ne garde la conversation : `facadeChatBlocker` et la
|
||||
décision prise une fois par processus disparaissent. Le Go la remet dès
|
||||
que `LOKI_CHAT=rust`, l'adresse de la façade et le jeton sont là.
|
||||
|
||||
### Rust
|
||||
|
||||
- `ChatService::assemble_turn`, partagé par le tour et la relance du builder
|
||||
du mode Code :
|
||||
- rafraîchi : instantané neuf, blocs `<context_update>` retirés de
|
||||
l'historique ;
|
||||
- mise à jour : posée en tête du dernier message utilisateur
|
||||
(`prependCtxUpdate`), dans la discussion et dans la requête ; l'état
|
||||
annoncé ne bouge que si elle a trouvé où se poser ;
|
||||
- sans la clé : la discussion qui porte encore des blocs en est nettoyée,
|
||||
comme avant.
|
||||
- `TurnHost::project_refresh` : après une compaction ou une réduction en
|
||||
plein tour, le bloc vivant remplace le figé (`replaceProjectHead`) et les
|
||||
mises à jour sont retirées. L'instantané posé devient celui de la
|
||||
discussion à la fin du tour ou de la passe. Le retrait d'images gardées ne
|
||||
touche pas au début du prompt : pas de rafraîchissement.
|
||||
- Le préchauffage demande la vue en `dry`. Il garde les mises à jour de
|
||||
l'historique tant que le bloc n'est pas rafraîchi, comme le vrai tour.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go (`facade_projsnap_test.go`) :
|
||||
- la séquence assemblée par la façade égale celle de `turnView`, outils et
|
||||
instantané compris ;
|
||||
- une page ajoutée : bloc gardé, mise à jour égale à celle de `turnView`,
|
||||
état annoncé suivi ;
|
||||
- sans changement : rien à poser ;
|
||||
- `dry` sans rien à ranger ;
|
||||
- clé changée : rafraîchi, bloc vivant en préchauffage ;
|
||||
- rafraîchissement en plein tour : bloc vivant, instantané à la clé du
|
||||
tour.
|
||||
- Go (`facade_chat_test.go`) : avec toutes les clés opt-in posées, la façade
|
||||
tient la conversation ; sans adresse ou sans `LOKI_CHAT`, le Go et
|
||||
pourquoi.
|
||||
- Rust, `chat/compact` : mise à jour en tête (texte, parties, refus) ; bloc
|
||||
projet remplacé à sa place (cas du Go).
|
||||
- Rust, boucle du tour : rafraîchi après une compaction et une réduction ;
|
||||
pas après un retrait d'images gardées.
|
||||
- Rust, vrai hôte (`tests/lot6.rs`) :
|
||||
- instantané rangé ;
|
||||
- mise à jour posée dans la requête et l'historique (marque retirée à
|
||||
l'envoi) ;
|
||||
- rafraîchissement qui retire les mises à jour ;
|
||||
- compaction en plein tour : bloc vivant envoyé, instantané rangé ;
|
||||
- préchauffage en `dry`.
|
||||
@@ -39,52 +39,14 @@ const (
|
||||
)
|
||||
|
||||
// facadeChat : la façade tient-elle la conversation ? Il faut aussi son
|
||||
// adresse et le jeton interne, sans lesquels rien ne peut lui être remis — et
|
||||
// aucun réglage qu'elle ne sait pas honorer (facadeChatBlocker).
|
||||
// adresse et le jeton interne, sans lesquels rien ne peut lui être remis.
|
||||
// Plus aucun réglage ne garde la conversation ici : la façade honore
|
||||
// NUDGE_IN_TOOL (lot 23), les motifs de relance personnalisés (lot 30),
|
||||
// SIDE_SLOT et SLOT_PERSIST (lot 31), KEEP_TURN_IMAGES (lot 32), PREWARM
|
||||
// (lot 33) et PROJ_SNAPSHOT (lot 34, facade_projsnap.go).
|
||||
func facadeChat() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != "" && facadeChatDecision() == ""
|
||||
}
|
||||
|
||||
// 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é). 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) ; ni PREWARM (lot 33) :
|
||||
// la façade assemble le préchauffage, le Go l'inscrit et l'annule
|
||||
// (facade_prewarm.go).
|
||||
func facadeChatBlocker(cfg map[string]string) string {
|
||||
switch {
|
||||
case projSnapEnabled(cfg):
|
||||
return "PROJ_SNAPSHOT"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Décision prise UNE fois par process : passer la conversation d'un détenteur
|
||||
// à l'autre en cours de route laisserait chacun avec sa copie — un réglage
|
||||
// changé vaut au redémarrage suivant. La façade la lit au démarrage
|
||||
// (/internal/v1/chat/holder) et s'y range.
|
||||
var (
|
||||
facadeDecisionMu sync.Mutex
|
||||
facadeDecisionDone bool
|
||||
facadeDecisionWhy string
|
||||
)
|
||||
|
||||
func facadeChatDecision() string {
|
||||
facadeDecisionMu.Lock()
|
||||
defer facadeDecisionMu.Unlock()
|
||||
if !facadeDecisionDone {
|
||||
facadeDecisionWhy = facadeChatBlocker(ReadConfig())
|
||||
facadeDecisionDone = true
|
||||
if facadeDecisionWhy != "" {
|
||||
fmt.Fprintf(os.Stderr, "[chat] %s posé : la conversation reste tenue par le Go (LOKI_CHAT=rust ignoré)\n", facadeDecisionWhy)
|
||||
}
|
||||
}
|
||||
return facadeDecisionWhy
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
}
|
||||
|
||||
// handleInternalChatHolder : qui tient la conversation ({"holder":"rust"} ou
|
||||
@@ -98,7 +60,7 @@ func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, http.StatusOK, map[string]any{"holder": "rust"})
|
||||
return
|
||||
}
|
||||
why := facadeChatDecision()
|
||||
why := "adresse de la façade ou jeton interne manquant"
|
||||
if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") {
|
||||
why = facadeChatEnv + " absent"
|
||||
}
|
||||
|
||||
@@ -27,22 +27,8 @@ type fausseFacade struct {
|
||||
internalBody string
|
||||
}
|
||||
|
||||
// oublierDecisionFacade : la décision « qui tient la conversation » est prise
|
||||
// une fois par process ; chaque test repart de zéro.
|
||||
func oublierDecisionFacade(t *testing.T) {
|
||||
t.Helper()
|
||||
reset := func() {
|
||||
facadeDecisionMu.Lock()
|
||||
facadeDecisionDone, facadeDecisionWhy = false, ""
|
||||
facadeDecisionMu.Unlock()
|
||||
}
|
||||
reset()
|
||||
t.Cleanup(reset)
|
||||
}
|
||||
|
||||
func demarrerFacade(t *testing.T) *fausseFacade {
|
||||
t.Helper()
|
||||
oublierDecisionFacade(t)
|
||||
f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
@@ -266,56 +252,36 @@ func TestFacadeActivationDeProjet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Un réglage que la façade ne sait pas honorer garde la conversation ici :
|
||||
// rien ne lui est remis, et elle l'apprend au démarrage. La décision tient
|
||||
// jusqu'au redémarrage.
|
||||
func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
|
||||
// Plus aucun réglage ne garde la conversation ici (lot 34) : avec chacune des
|
||||
// clés opt-in posée, la façade la tient et le trafic de chat lui est remis.
|
||||
// Sans LOKI_CHAT=rust, ou sans son adresse : le Go, et pourquoi.
|
||||
func TestFacadeAucunReglageNeGardeLaConversation(t *testing.T) {
|
||||
testHome(t)
|
||||
f := demarrerFacade(t)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
for _, k := range []string{"PROJ_SNAPSHOT", "NUDGE_IN_TOOL", "SIDE_SLOT", "SLOT_PERSIST", "KEEP_TURN_IMAGES", "PREWARM"} {
|
||||
if err := SetConfigKey(k, "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
putStr(bkState, "retry_patterns", `["oups"]`)
|
||||
if !facadeChat() {
|
||||
t.Fatal("réglages opt-in posés : la façade doit tenir la conversation")
|
||||
}
|
||||
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"holder":"rust"`) {
|
||||
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("PROJ_SNAPSHOT", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !facadeChat() {
|
||||
t.Fatal("décision changée en cours de route")
|
||||
}
|
||||
oublierDecisionFacade(t)
|
||||
if facadeChat() {
|
||||
t.Fatal("PROJ_SNAPSHOT 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(), "PROJ_SNAPSHOT") {
|
||||
t.Fatalf("détenteur : %s", w.Body.String())
|
||||
}
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
rec := httptest.NewRecorder()
|
||||
withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`)))
|
||||
if rec.Body.String() != "go" || len(f.recu()) != 0 {
|
||||
t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu())
|
||||
}
|
||||
for k, want := range map[string]string{
|
||||
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "",
|
||||
"SLOT_PERSIST": "", "KEEP_TURN_IMAGES": "", "PREWARM": "",
|
||||
} {
|
||||
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
|
||||
t.Errorf("%s : %q", k, got)
|
||||
}
|
||||
}
|
||||
if got := facadeChatBlocker(map[string]string{}); got != "" {
|
||||
t.Errorf("sans réglage : %q", got)
|
||||
}
|
||||
// Les motifs de relance personnalisés ne gardent plus la conversation.
|
||||
putStr(bkState, "retry_patterns", `["oups"]`)
|
||||
if got := facadeChatBlocker(map[string]string{}); got != "" {
|
||||
t.Errorf("retry_patterns : %q", got)
|
||||
if rec.Body.String() == "go" || len(f.recu()) != 1 {
|
||||
t.Fatalf("pas remis à la façade : %q %v", rec.Body.String(), f.recu())
|
||||
}
|
||||
putStr(bkState, "retry_patterns", "")
|
||||
// Sans LOKI_CHAT=rust : le Go, et pourquoi.
|
||||
oublierDecisionFacade(t)
|
||||
t.Setenv(facadeAddrEnv, "")
|
||||
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "adresse de la façade") {
|
||||
t.Fatalf("sans adresse : %s", w.Body.String())
|
||||
}
|
||||
t.Setenv(facadeChatEnv, "")
|
||||
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "LOKI_CHAT absent") {
|
||||
t.Fatalf("sans la variable : %s", w.Body.String())
|
||||
@@ -1076,7 +1042,6 @@ func TestFacadeBackends(t *testing.T) {
|
||||
func TestFacadeSondeDeGabarit(t *testing.T) {
|
||||
testHome(t)
|
||||
freshTplProbe(t)
|
||||
oublierDecisionFacade(t)
|
||||
verdict := `{"ok":true,"result":{"preserves_history":"no","prefix_stable":"no","renders_reasoning":"yes","model":"m1.gguf","tools":2,"at":"2026-10-15T20:00:00.5Z"}}`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(internalTokenHeader) != jetonTest || r.URL.Path != "/internal/facade/v1/chat/tpl" {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// facade_projsnap.go — PROJ_SNAPSHOT pour le tour tenu par la façade (lot 34
|
||||
// de la migration). La façade range l'instantané avec la discussion (il est
|
||||
// persisté avec elle, champ proj_snap) et pose les <context_update> dans son
|
||||
// historique ; le Go, qui lit le contexte du projet (description, index
|
||||
// mémoire, trackers, consignes du dépôt, date et dossier de travail), décide :
|
||||
//
|
||||
// - /internal/v1/turn/view, avec la clé et l'agent : la tête du tour porte
|
||||
// le bloc figé (ou le vivant s'il faut rafraîchir), et projsnap dit quoi
|
||||
// faire de la discussion — rafraîchir (instantané neuf, blocs retirés de
|
||||
// l'historique) ou poser une mise à jour en tête du dernier message
|
||||
// utilisateur. dry : la même décision pour le préchauffage, sans rien
|
||||
// rendre à ranger ;
|
||||
// - /internal/v1/turn/projsnap/refresh : une compaction ou une réduction
|
||||
// réécrit l'historique en plein tour, le bloc vivant remplace le figé et
|
||||
// devient l'instantané du tour suivant.
|
||||
|
||||
// projSnapView : les pièces de turnView avec la clé (assembleTurn, chemin
|
||||
// PROJ_SNAPSHOT). snap : l'instantané de la discussion (nil : aucun).
|
||||
func projSnapView(caps Caps, snap *projSnapshot, dry bool) map[string]any {
|
||||
cfg := ReadConfig()
|
||||
// Date et dossier de travail : dans le bloc, plus dans le système.
|
||||
caps.envInCtx = true
|
||||
asOf := projSnapNow()
|
||||
live := projSnapCapture(caps, asOf)
|
||||
key := projSnapKey(caps, cfg)
|
||||
tools := EnabledTools(caps)
|
||||
if tools == nil {
|
||||
tools = []Tool{}
|
||||
}
|
||||
head := []Message{}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
head = append(head, Message{Role: "system", Content: sp})
|
||||
}
|
||||
sysHash := projSnapSysHash(InjectSkills(append([]Message(nil), head...), caps, tools), tools)
|
||||
refresh, delta, tok := projSnapDecide(snap, live, key, sysHash, asOf)
|
||||
block := live.msgs
|
||||
if !refresh {
|
||||
block = snap.Msgs
|
||||
}
|
||||
out := map[string]any{"refresh": refresh, "key": key, "sys_hash": sysHash}
|
||||
if !dry {
|
||||
switch {
|
||||
case refresh:
|
||||
out["snap"] = newProjSnapshot(live, key, sysHash)
|
||||
case delta != "":
|
||||
// Rangé par la façade seulement si la mise à jour a trouvé un
|
||||
// message utilisateur où se poser (projSnapApplyLocked).
|
||||
next := *snap
|
||||
next.State = live.state
|
||||
next.DeltaTok += tok
|
||||
out["snap"] = &next
|
||||
out["delta"] = delta
|
||||
default:
|
||||
out["snap"] = snap
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"head": append(head, block...),
|
||||
"prefix": injectPrefix(caps, tools),
|
||||
"tools": tools,
|
||||
"projsnap": out,
|
||||
}
|
||||
}
|
||||
|
||||
// handleInternalTurnProjSnapRefresh : POST {"caps","key","sys_hash"} →
|
||||
// {"block":[…],"snap":{…}} — le bloc vivant et l'instantané qu'il devient
|
||||
// (projSnapTurn.refresh).
|
||||
func handleInternalTurnProjSnapRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Caps Caps `json:"caps"`
|
||||
Key string `json:"key"`
|
||||
SysHash string `json:"sys_hash"`
|
||||
}
|
||||
if !readInternalJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
caps := in.Caps
|
||||
caps.envInCtx = true
|
||||
live := projSnapCapture(caps, projSnapNow())
|
||||
block := live.msgs
|
||||
if block == nil {
|
||||
block = []Message{}
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{
|
||||
"block": block,
|
||||
"snap": newProjSnapshot(live, in.Key, in.SysHash),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// projSnapFacade : /internal/v1/turn/view avec la clé, décodé.
|
||||
type projSnapFacadeView struct {
|
||||
Head []Message `json:"head"`
|
||||
Prefix string `json:"prefix"`
|
||||
Tools []Tool `json:"tools"`
|
||||
ProjSnap struct {
|
||||
Refresh bool `json:"refresh"`
|
||||
Delta string `json:"delta"`
|
||||
Snap *projSnapshot `json:"snap"`
|
||||
Key string `json:"key"`
|
||||
SysHash string `json:"sys_hash"`
|
||||
} `json:"projsnap"`
|
||||
}
|
||||
|
||||
func projSnapFacade(t *testing.T, caps Caps, snap *projSnapshot, dry bool) projSnapFacadeView {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{"caps": caps, "snap": snap, "dry": dry})
|
||||
w := appelOAIInterne(t, "POST", "/internal/v1/turn/view", string(body))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("vue : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var v projSnapFacadeView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// msgsJSON : la séquence telle qu'elle part (forme JSON, sans les champs
|
||||
// propres au Go).
|
||||
func msgsJSON(t *testing.T, msgs []Message) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(msgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// La façade assemble ce que turnView aurait envoyé : instantané pris au
|
||||
// premier tour, mise à jour posée en tête du message suivant après un
|
||||
// changement, l'instantané de la façade rangé et relu.
|
||||
func TestFacadeProjSnapCommeTurnView(t *testing.T) {
|
||||
projSnapSetup(t, true)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
|
||||
// Premier tour : rien de figé, instantané neuf.
|
||||
v := projSnapFacade(t, caps, nil, false)
|
||||
if !v.ProjSnap.Refresh || v.ProjSnap.Snap == nil || v.ProjSnap.Delta != "" {
|
||||
t.Fatalf("premier tour : %+v", v.ProjSnap)
|
||||
}
|
||||
c := newTestConv()
|
||||
msgs := []Message{um("bonjour")}
|
||||
c.Messages = append([]Message(nil), msgs...)
|
||||
sent, tools, _ := c.turnView(caps, c.epoch, msgs, true)
|
||||
got := withPrefix(append(append([]Message(nil), v.Head...), msgs...), v.Prefix)
|
||||
if msgsJSON(t, got) != msgsJSON(t, sent) {
|
||||
t.Fatalf("séquence différente de turnView :\n%s\n%s", msgsJSON(t, got), msgsJSON(t, sent))
|
||||
}
|
||||
if !reflect.DeepEqual(jsonValue(t, v.Tools), jsonValue(t, tools)) {
|
||||
t.Fatal("outils différents de turnView")
|
||||
}
|
||||
if !reflect.DeepEqual(v.ProjSnap.Snap.State, c.ProjSnap.State) || v.ProjSnap.Snap.Key != c.ProjSnap.Key || v.ProjSnap.Snap.SysHash != c.ProjSnap.SysHash {
|
||||
t.Fatalf("instantané différent :\n%+v\n%+v", v.ProjSnap.Snap, c.ProjSnap)
|
||||
}
|
||||
if v.ProjSnap.Key != c.ProjSnap.Key || v.ProjSnap.SysHash != c.ProjSnap.SysHash {
|
||||
t.Fatal("clé du tour différente")
|
||||
}
|
||||
|
||||
// Une page ajoutée : bloc figé gardé, mise à jour annoncée.
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := v.ProjSnap.Snap
|
||||
v2 := projSnapFacade(t, caps, snap, false)
|
||||
if v2.ProjSnap.Refresh || !strings.Contains(v2.ProjSnap.Delta, "](nas.md)") {
|
||||
t.Fatalf("mise à jour attendue : %+v", v2.ProjSnap)
|
||||
}
|
||||
if msgsJSON(t, v2.Head) != msgsJSON(t, v.Head) {
|
||||
t.Fatal("bloc figé non gardé")
|
||||
}
|
||||
c.Messages = append(c.Messages, am("ok."), um("et le NAS ?"))
|
||||
msgs2 := append([]Message(nil), c.Messages...)
|
||||
sent2, _, _ := c.turnView(caps, c.epoch, msgs2, true)
|
||||
last := msgText(sent2[len(sent2)-1])
|
||||
if last != v2.ProjSnap.Delta+"et le NAS ?" {
|
||||
t.Fatalf("mise à jour différente de turnView :\n%q\n%q", last, v2.ProjSnap.Delta+"et le NAS ?")
|
||||
}
|
||||
next := v2.ProjSnap.Snap
|
||||
if next == nil || next.State.Mem["nas.md"] == "" || next.DeltaTok <= snap.DeltaTok || !reflect.DeepEqual(next.State, c.ProjSnap.State) {
|
||||
t.Fatalf("état annoncé non suivi : %+v", next)
|
||||
}
|
||||
// Sans nouveau changement : rien à poser, instantané inchangé.
|
||||
v3 := projSnapFacade(t, caps, next, false)
|
||||
if v3.ProjSnap.Refresh || v3.ProjSnap.Delta != "" || !reflect.DeepEqual(v3.ProjSnap.Snap.State, next.State) {
|
||||
t.Fatalf("tour sans changement : %+v", v3.ProjSnap)
|
||||
}
|
||||
// dry (préchauffage) : la même décision, rien à ranger.
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := projSnapFacade(t, caps, next, true)
|
||||
if d.ProjSnap.Refresh || d.ProjSnap.Snap != nil || d.ProjSnap.Delta != "" || msgsJSON(t, d.Head) != msgsJSON(t, v.Head) {
|
||||
t.Fatalf("préchauffage : %+v", d.ProjSnap)
|
||||
}
|
||||
// Instantané d'un autre lancement (clé) : rafraîchi.
|
||||
stale := *next
|
||||
stale.Key = "autre"
|
||||
if r := projSnapFacade(t, caps, &stale, false); !r.ProjSnap.Refresh {
|
||||
t.Fatal("clé changée : rafraîchissement attendu")
|
||||
}
|
||||
if r := projSnapFacade(t, caps, &stale, true); !r.ProjSnap.Refresh || msgsJSON(t, r.Head) == msgsJSON(t, v.Head) {
|
||||
t.Fatal("préchauffage d'un instantané périmé : bloc vivant attendu")
|
||||
}
|
||||
}
|
||||
|
||||
// Compaction en plein tour : le bloc vivant, et l'instantané qu'il devient
|
||||
// avec la clé et le système du tour.
|
||||
func TestFacadeProjSnapRafraichi(t *testing.T) {
|
||||
projSnapSetup(t, true)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := appelOAIInterne(t, "POST", "/internal/v1/turn/projsnap/refresh", `{"caps":{"Agent":true,"Mem":"always"},"key":"k1","sys_hash":"s1"}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("rafraîchissement : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Block []Message `json:"block"`
|
||||
Snap *projSnapshot `json:"snap"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live := projSnapCapture(Caps{Agent: true, Mem: MemAlways, envInCtx: true}, projSnapNow())
|
||||
if msgsJSON(t, out.Block) != msgsJSON(t, live.msgs) || len(out.Block) == 0 {
|
||||
t.Fatalf("bloc vivant différent : %s", msgsJSON(t, out.Block))
|
||||
}
|
||||
if out.Snap == nil || out.Snap.Key != "k1" || out.Snap.SysHash != "s1" || out.Snap.State.Mem["nas.md"] == "" {
|
||||
t.Fatalf("instantané : %+v", out.Snap)
|
||||
}
|
||||
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/projsnap/refresh", `{`); w.Code != 400 {
|
||||
t.Fatalf("corps illisible : %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// withPrefix : ce que la façade fait du préambule rendu (inject_prefix,
|
||||
// comme InjectSkills avec ce texte).
|
||||
func withPrefix(msgs []Message, prefix string) []Message {
|
||||
if prefix == "" {
|
||||
return msgs
|
||||
}
|
||||
if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) {
|
||||
existing, _ := msgs[0].Content.(string)
|
||||
return append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...)
|
||||
}
|
||||
return append([]Message{{Role: "system", Content: prefix}}, msgs...)
|
||||
}
|
||||
@@ -73,6 +73,8 @@ func withInternalAPI(next http.Handler) http.Handler {
|
||||
handleInternalOAIServed(w, r)
|
||||
case "/internal/v1/turn/perf":
|
||||
handleInternalTurnPerf(w, r)
|
||||
case "/internal/v1/turn/projsnap/refresh":
|
||||
handleInternalTurnProjSnapRefresh(w, r)
|
||||
case "/internal/v1/turn/slot":
|
||||
handleInternalTurnSlot(w, r)
|
||||
case "/internal/v1/prewarm/check":
|
||||
|
||||
@@ -80,20 +80,24 @@ func handleInternalTurnStart(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleInternalTurnView : les pièces de turnView (assembleTurn sans
|
||||
// PROJ_SNAPSHOT) que la façade assemble autour de l'historique : head part
|
||||
// devant les messages, puis prefix (InjectSkills) fusionné dans le premier
|
||||
// système qui n'est pas un message projet, ou posé en tête.
|
||||
// handleInternalTurnView : les pièces de turnView (assembleTurn) que la
|
||||
// façade assemble autour de l'historique : head part devant les messages, puis
|
||||
// prefix (InjectSkills) fusionné dans le premier système qui n'est pas un
|
||||
// message projet, ou posé en tête. Avec PROJ_SNAPSHOT et l'agent, la tête
|
||||
// porte le bloc figé de la discussion (snap) et projsnap dit quoi en faire
|
||||
// (facade_projsnap.go) ; dry : pour le préchauffage, rien à ranger.
|
||||
func handleInternalTurnView(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Caps Caps `json:"caps"`
|
||||
Caps Caps `json:"caps"`
|
||||
Snap *projSnapshot `json:"snap"`
|
||||
Dry bool `json:"dry"`
|
||||
}
|
||||
if !readInternalJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
caps := in.Caps
|
||||
if caps.Agent && projSnapEnabled(ReadConfig()) {
|
||||
sendJSON(w, http.StatusConflict, map[string]any{"error": "PROJ_SNAPSHOT actif : le bloc projet figé reste tenu par le Go"})
|
||||
sendJSON(w, http.StatusOK, projSnapView(caps, in.Snap, in.Dry))
|
||||
return
|
||||
}
|
||||
head := []Message{}
|
||||
|
||||
@@ -101,7 +101,10 @@ func TestTourInterneVue(t *testing.T) {
|
||||
if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true}}`, 409)
|
||||
// Avec la clé : le bloc figé et la décision (facade_projsnap.go).
|
||||
if got := turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true}}`, 200); got["projsnap"] == nil {
|
||||
t.Fatalf("PROJ_SNAPSHOT sans décision : %v", got)
|
||||
}
|
||||
turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":false,"Mem":"off"}}`, 200)
|
||||
}
|
||||
|
||||
|
||||
@@ -510,6 +510,57 @@ pub(crate) fn strip_context_updates(msgs: Vec<Message>) -> Vec<Message> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `prependCtxUpdate` : la mise à jour du bloc projet (PROJ_SNAPSHOT) posée en
|
||||
/// tête d'un message `user`, marqué `ctx_upd` ; `None` : pas un message où la
|
||||
/// poser.
|
||||
pub(crate) fn prepend_ctx_update(m: &Message, block: &str) -> Option<Message> {
|
||||
if m.role != "user" {
|
||||
return None;
|
||||
}
|
||||
let mut out = m.clone();
|
||||
match &m.content {
|
||||
Some(Value::String(c)) => out.content = Some(Value::String(format!("{block}{c}"))),
|
||||
Some(Value::Array(parts)) => {
|
||||
let mut v = Vec::with_capacity(parts.len() + 1);
|
||||
v.push(serde_json::json!({ "type": "text", "text": block }));
|
||||
v.extend(parts.iter().cloned());
|
||||
out.content = Some(Value::Array(v));
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
out.ctx_upd = true;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `replaceProjectHead` : le bloc projet de tête (hors rappel des pages lues,
|
||||
/// qui appartient à l'historique) remplacé par `block`, à la même place.
|
||||
pub(crate) fn replace_project_head(msgs: Vec<Message>, block: &[Message]) -> Vec<Message> {
|
||||
let end = msgs.iter().take_while(|m| m.role == "system").count();
|
||||
let mut out = Vec::with_capacity(msgs.len() + block.len());
|
||||
let mut inserted = false;
|
||||
let mut rest = msgs.into_iter();
|
||||
for m in rest.by_ref().take(end) {
|
||||
let reminder = matches!(&m.content, Some(Value::String(s)) if s.starts_with(super::wire::MEM_REMINDER_PREFIX));
|
||||
if super::wire::is_project_system(&m) && !reminder {
|
||||
if !inserted {
|
||||
out.extend(block.iter().cloned());
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if reminder && !inserted {
|
||||
out.extend(block.iter().cloned());
|
||||
inserted = true;
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
if !inserted {
|
||||
out.extend(block.iter().cloned());
|
||||
}
|
||||
out.extend(rest);
|
||||
out
|
||||
}
|
||||
|
||||
// ---- bornes et assemblage --------------------------------------------------
|
||||
|
||||
/// `compactBounds` : frontières tête/queue. Tête = messages système de tête ;
|
||||
@@ -1844,6 +1895,77 @@ impl<S: Summarizer, R: RecallArchive> Compactor<'_, S, R> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── PROJ_SNAPSHOT (lot 34, cas de chat_projsnap_test.go) ──
|
||||
|
||||
#[test]
|
||||
fn mise_a_jour_en_tete_du_message() {
|
||||
let block = format!("{CTX_UPD_OPEN} at=\"x\">\n+ a\n{CTX_UPD_CLOSE}\n\n");
|
||||
let m = prepend_ctx_update(&um("suite"), &block).expect("message user");
|
||||
assert!(m.ctx_upd);
|
||||
assert_eq!(m.content, Some(Value::String(format!("{block}suite"))));
|
||||
// Retirée à l'identique.
|
||||
assert_eq!(without_ctx_update(&m).unwrap().content, um("suite").content);
|
||||
let parts = Message {
|
||||
role: "user".into(),
|
||||
content: Some(
|
||||
serde_json::json!([{"type": "image_url", "image_url": {"url": "loki-img:a.png"}}]),
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
let m = prepend_ctx_update(&parts, &block).unwrap();
|
||||
assert_eq!(
|
||||
m.content.as_ref().unwrap()[0],
|
||||
serde_json::json!({"type": "text", "text": block})
|
||||
);
|
||||
assert_eq!(m.content.as_ref().unwrap()[1]["type"], "image_url");
|
||||
assert!(prepend_ctx_update(&tm("r"), &block).is_none());
|
||||
let empty = Message {
|
||||
role: "user".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(prepend_ctx_update(&empty, &block).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bloc_projet_remplace_a_sa_place() {
|
||||
use super::super::wire::{MEM_INDEX_PREFIX, MEM_REMINDER_PREFIX, PROJECT_CONTEXT_PREFIX};
|
||||
let sys = |c: String| Message {
|
||||
role: "system".into(),
|
||||
content: Some(Value::String(c)),
|
||||
..Default::default()
|
||||
};
|
||||
let msgs = vec![
|
||||
sys("base".into()),
|
||||
sys(format!("{PROJECT_CONTEXT_PREFIX} — vieux")),
|
||||
sys(format!("{MEM_INDEX_PREFIX} vieux")),
|
||||
sys(format!("{MEM_REMINDER_PREFIX} a.md")),
|
||||
um("q"),
|
||||
];
|
||||
let block = vec![sys(format!("{MEM_INDEX_PREFIX} neuf"))];
|
||||
let out = replace_project_head(msgs, &block);
|
||||
let got: Vec<String> = out.iter().map(msg_text).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
"base".to_string(),
|
||||
format!("{MEM_INDEX_PREFIX} neuf"),
|
||||
format!("{MEM_REMINDER_PREFIX} a.md"),
|
||||
"q".into()
|
||||
]
|
||||
);
|
||||
// Bloc vide au départ : inséré après le système commun.
|
||||
let out = replace_project_head(vec![sys("base".into()), um("q")], &block);
|
||||
assert_eq!(msg_text(&out[1]), format!("{MEM_INDEX_PREFIX} neuf"));
|
||||
assert_eq!(msg_text(&out[2]), "q");
|
||||
// Rappel des pages lues avant tout bloc : le bloc se pose devant lui.
|
||||
let out = replace_project_head(
|
||||
vec![sys(format!("{MEM_REMINDER_PREFIX} a.md")), um("q")],
|
||||
&block,
|
||||
);
|
||||
assert_eq!(msg_text(&out[0]), format!("{MEM_INDEX_PREFIX} neuf"));
|
||||
assert_eq!(msg_text(&out[1]), format!("{MEM_REMINDER_PREFIX} a.md"));
|
||||
}
|
||||
use crate::chat::types::{ToolCall, ToolCallFunc, ToolFunction};
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
//! corrections du builder) suit le tour, avant la compaction de fin
|
||||
//! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé.
|
||||
//!
|
||||
//! Écart assumé (reste au Go) : PROJ_SNAPSHOT (refusé par `/turn/view`). Le
|
||||
//! préchauffage (PREWARM, lot 33) part en fin de tour ([`super::prewarm`]).
|
||||
//! Le bloc projet figé (PROJ_SNAPSHOT, lot 34) : le Go décide avec la vue du
|
||||
//! tour, la façade range l'instantané et pose les mises à jour
|
||||
//! ([`ChatService::assemble_turn`]). Le préchauffage (PREWARM, lot 33) part en
|
||||
//! fin de tour ([`super::prewarm`]).
|
||||
//! 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
|
||||
@@ -46,18 +48,19 @@ use tokio::sync::watch;
|
||||
use super::compact::{
|
||||
CompactCaps, CompactConfig, CompactOpts, Compactor, TurnView, compact_needed, compact_opts_for,
|
||||
ctx_pending, drop_kept_images, estimate_tokens, log_compact, log_ctx_estimate,
|
||||
log_kept_images_dropped, strip_context_updates,
|
||||
log_kept_images_dropped, prepend_ctx_update, 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, keep_images_state,
|
||||
ChatShared, EngineSummarizer, GoTurnHost, SnapTurn, 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::persist::ProjSnapshot;
|
||||
use super::sessions::{GoSessionHooks, Sessions};
|
||||
use super::turn::{Cancel, TurnConfig, run_turn};
|
||||
use super::types::{Message, ReasoningEcho, StreamEvent, Tool};
|
||||
@@ -177,6 +180,42 @@ pub(super) struct TurnEnv {
|
||||
pub(super) summarizer: EngineSummarizer,
|
||||
}
|
||||
|
||||
/// La décision PROJ_SNAPSHOT rendue avec la vue du tour (`projsnap`).
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub(super) struct SnapDecision {
|
||||
/// Instantané neuf : blocs `<context_update>` retirés de l'historique.
|
||||
#[serde(default)]
|
||||
pub(super) refresh: bool,
|
||||
/// Mise à jour à poser en tête du dernier message utilisateur.
|
||||
#[serde(default)]
|
||||
pub(super) delta: String,
|
||||
/// L'instantané à ranger (absent en préchauffage).
|
||||
#[serde(default)]
|
||||
pub(super) snap: Option<ProjSnapshot>,
|
||||
#[serde(default)]
|
||||
pub(super) key: String,
|
||||
#[serde(default)]
|
||||
pub(super) sys_hash: String,
|
||||
}
|
||||
|
||||
/// Les pièces de la vue d'envoi.
|
||||
pub(super) struct ViewParts {
|
||||
pub(super) head: Vec<Message>,
|
||||
pub(super) prefix: String,
|
||||
pub(super) tools: Vec<Tool>,
|
||||
pub(super) snap: Option<SnapDecision>,
|
||||
}
|
||||
|
||||
/// Une étape assemblée : ce qui part, les outils, l'historique d'où part la
|
||||
/// vue (`historyFromView`), et de quoi rafraîchir le bloc projet en plein
|
||||
/// tour (PROJ_SNAPSHOT).
|
||||
pub(super) struct Assembled {
|
||||
pub(super) sent: Vec<Message>,
|
||||
pub(super) tools: Vec<Tool>,
|
||||
pub(super) hist: Vec<Message>,
|
||||
pub(super) snap_turn: Option<SnapTurn>,
|
||||
}
|
||||
|
||||
/// Le chat tenu par la façade : la discussion ouverte, ses tours et sa file.
|
||||
pub struct ChatService {
|
||||
pub sessions: Arc<Sessions<GoSessionHooks>>,
|
||||
@@ -523,23 +562,34 @@ impl ChatService {
|
||||
Some(compacted)
|
||||
}
|
||||
|
||||
/// `/internal/v1/turn/view` : tête (prompt du preset, consignes,
|
||||
/// projet), préambule et outils du tour ; `Err` : le message à journaliser.
|
||||
/// Les pièces de la vue d'envoi (`/internal/v1/turn/view`) : tête du Go
|
||||
/// (prompt du preset, consignes, projet), préambule, outils ; avec
|
||||
/// PROJ_SNAPSHOT et l'agent, la décision sur le bloc figé (`snap` :
|
||||
/// l'instantané de la discussion ; `dry` : préchauffage, rien à ranger).
|
||||
pub(super) async fn turn_view(
|
||||
&self,
|
||||
caps: &Value,
|
||||
) -> Result<(Vec<Message>, String, Vec<Tool>), String> {
|
||||
snap: Option<&ProjSnapshot>,
|
||||
dry: bool,
|
||||
) -> Result<ViewParts, String> {
|
||||
match self
|
||||
.shared
|
||||
.store
|
||||
.internal_post("/internal/v1/turn/view", &json!({ "caps": caps }))
|
||||
.internal_post(
|
||||
"/internal/v1/turn/view",
|
||||
&json!({ "caps": caps, "snap": snap, "dry": dry }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((s, v)) if s.as_u16() == 200 => Ok((
|
||||
serde_json::from_value::<Vec<Message>>(v["head"].clone()).unwrap_or_default(),
|
||||
v["prefix"].as_str().unwrap_or("").to_string(),
|
||||
serde_json::from_value::<Vec<Tool>>(v["tools"].clone()).unwrap_or_default(),
|
||||
)),
|
||||
Ok((s, v)) if s.as_u16() == 200 => Ok(ViewParts {
|
||||
head: serde_json::from_value::<Vec<Message>>(v["head"].clone()).unwrap_or_default(),
|
||||
prefix: v["prefix"].as_str().unwrap_or("").to_string(),
|
||||
tools: serde_json::from_value::<Vec<Tool>>(v["tools"].clone()).unwrap_or_default(),
|
||||
snap: v
|
||||
.get("projsnap")
|
||||
.filter(|p| p.is_object())
|
||||
.and_then(|p| serde_json::from_value(p.clone()).ok()),
|
||||
}),
|
||||
Ok((_, v)) => Err(v["error"]
|
||||
.as_str()
|
||||
.unwrap_or("vue du tour refusée")
|
||||
@@ -548,6 +598,90 @@ impl ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/// `turnView` : la séquence envoyée pour une étape de la discussion (tour,
|
||||
/// relance du builder) — tête du Go, historique, préambule. Avec
|
||||
/// PROJ_SNAPSHOT : l'instantané de la discussion rangé, rafraîchi (blocs
|
||||
/// `<context_update>` retirés de l'historique), ou complété d'une mise à
|
||||
/// jour posée en tête du dernier message utilisateur, dans la discussion
|
||||
/// et dans `msgs`. Sans la clé, une discussion qui porte encore des blocs
|
||||
/// d'une époque où elle servait en est nettoyée.
|
||||
pub(super) async fn assemble_turn(
|
||||
&self,
|
||||
epoch: i64,
|
||||
caps: &Value,
|
||||
mut msgs: Vec<Message>,
|
||||
) -> Result<Assembled, String> {
|
||||
let conv = self.sessions.conv();
|
||||
let snap = conv.with_state(|st| st.proj_snap.clone());
|
||||
let v = self.turn_view(caps, snap.as_ref(), false).await?;
|
||||
let agent = caps.get("Agent").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut snap_turn = None;
|
||||
let hist = match v.snap {
|
||||
Some(d) => {
|
||||
let applied = conv.with_state_if(epoch, |st| {
|
||||
if d.refresh {
|
||||
st.messages = strip_context_updates(std::mem::take(&mut st.messages));
|
||||
st.proj_snap = d.snap.clone();
|
||||
return (true, false);
|
||||
}
|
||||
if d.delta.is_empty() {
|
||||
return (false, false);
|
||||
}
|
||||
// Pas de message utilisateur où la poser : l'état annoncé
|
||||
// ne bouge pas, elle partira au tour suivant.
|
||||
let Some(m) = st
|
||||
.messages
|
||||
.last()
|
||||
.and_then(|m| prepend_ctx_update(m, &d.delta))
|
||||
else {
|
||||
return (false, false);
|
||||
};
|
||||
if let Some(last) = st.messages.last_mut() {
|
||||
*last = m;
|
||||
}
|
||||
st.proj_snap = d.snap.clone();
|
||||
(false, true)
|
||||
});
|
||||
if let Some((refresh, updated)) = applied {
|
||||
if refresh {
|
||||
msgs = strip_context_updates(msgs);
|
||||
}
|
||||
if updated
|
||||
&& let Some(m) = msgs.last().and_then(|m| prepend_ctx_update(m, &d.delta))
|
||||
&& let Some(last) = msgs.last_mut()
|
||||
{
|
||||
*last = m;
|
||||
}
|
||||
}
|
||||
snap_turn = Some(SnapTurn {
|
||||
caps: caps.clone(),
|
||||
key: d.key,
|
||||
sys_hash: d.sys_hash,
|
||||
});
|
||||
msgs
|
||||
}
|
||||
None => {
|
||||
if agent {
|
||||
conv.with_state_if(epoch, |st| {
|
||||
if st.proj_snap.is_some() || st.messages.iter().any(|m| m.ctx_upd) {
|
||||
st.proj_snap = None;
|
||||
st.messages = strip_context_updates(std::mem::take(&mut st.messages));
|
||||
}
|
||||
});
|
||||
}
|
||||
strip_context_updates(msgs)
|
||||
}
|
||||
};
|
||||
let mut sent = v.head;
|
||||
sent.extend(hist.iter().cloned());
|
||||
Ok(Assembled {
|
||||
sent: inject_prefix(sent, &v.prefix),
|
||||
tools: v.tools,
|
||||
hist,
|
||||
snap_turn,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configuration, point de chat et résumeur d'un tour (lus une fois).
|
||||
pub(super) async fn turn_env(&self) -> TurnEnv {
|
||||
let store = &self.shared.store;
|
||||
@@ -827,30 +961,21 @@ impl ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
// Vue d'envoi : tête du Go (prompt du preset, consignes, projet),
|
||||
// l'historique sans bloc <context_update>, le préambule.
|
||||
let (head, prefix, tools) = match self.turn_view(&prep.turn_caps).await {
|
||||
Ok(v) => v,
|
||||
// Vue d'envoi : tête du Go (prompt du preset, consignes, projet ou
|
||||
// bloc figé), l'historique, le préambule.
|
||||
let Assembled {
|
||||
sent,
|
||||
tools,
|
||||
hist,
|
||||
snap_turn,
|
||||
} = match self.assemble_turn(epoch, &prep.turn_caps, msgs).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
conv.append_delta(epoch, obj("error", e));
|
||||
self.finish(epoch, &cancel, lease, turn_start).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if agent {
|
||||
// Une discussion qui porte encore des blocs d'une époque où
|
||||
// PROJ_SNAPSHOT servait en est nettoyée (comme assembleTurn).
|
||||
conv.with_state_if(epoch, |st| {
|
||||
if st.proj_snap.is_some() || st.messages.iter().any(|m| m.ctx_upd) {
|
||||
st.proj_snap = None;
|
||||
st.messages = strip_context_updates(std::mem::take(&mut st.messages));
|
||||
}
|
||||
});
|
||||
}
|
||||
let hist = strip_context_updates(msgs);
|
||||
let mut sent = head;
|
||||
sent.extend(hist.iter().cloned());
|
||||
let sent = inject_prefix(sent, &prefix);
|
||||
|
||||
let temperature = if prep.temperature == 0.0 {
|
||||
0.7
|
||||
@@ -889,6 +1014,8 @@ impl ChatService {
|
||||
queued: Box::new(move || me.drain_queued(epoch)),
|
||||
perf_kind: super::perf::KIND_MAIN,
|
||||
side_slot: Default::default(),
|
||||
projsnap: snap_turn,
|
||||
snap_refreshed: Default::default(),
|
||||
};
|
||||
|
||||
// Chaque événement devient un delta du journal.
|
||||
@@ -967,6 +1094,9 @@ impl ChatService {
|
||||
}
|
||||
}
|
||||
};
|
||||
// Bloc projet rafraîchi par une compaction en cours de tour
|
||||
// (PROJ_SNAPSHOT) : c'est lui que le tour suivant renvoie.
|
||||
let refreshed = host.take_refreshed_snap();
|
||||
drop(host);
|
||||
|
||||
// Vue modèle rangée : outils puis réponse finale — sauf si un reset
|
||||
@@ -975,6 +1105,9 @@ impl ChatService {
|
||||
if let Some(base) = new_base.take() {
|
||||
st.messages = base;
|
||||
}
|
||||
if let Some(s) = refreshed {
|
||||
st.proj_snap = Some(s);
|
||||
}
|
||||
st.messages.extend(res.extra.iter().cloned());
|
||||
if !content.trim().is_empty() {
|
||||
let a = Message {
|
||||
|
||||
@@ -37,7 +37,7 @@ use super::compact::{
|
||||
parse_summary_response,
|
||||
};
|
||||
use super::engine::{EngineHttp, classify, friendly_error};
|
||||
use super::persist::{Persister, StoreSink, ToolResJob};
|
||||
use super::persist::{Persister, ProjSnapshot, StoreSink, ToolResJob};
|
||||
use super::stream::{CacheHintFn, PreflightFn, RetryPatterns};
|
||||
use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost};
|
||||
use super::types::{AskEvent, DiffLine, Message, Tool};
|
||||
@@ -366,6 +366,30 @@ pub struct GoTurnHost {
|
||||
/// La requête qui part va sur le second slot (posé par `engine_slot`,
|
||||
/// lu par `send` pour le ticket « en vol »).
|
||||
pub side_slot: AtomicBool,
|
||||
/// PROJ_SNAPSHOT : la clé et le système du tour (`None` sans la clé), et
|
||||
/// l'instantané posé par un rafraîchissement en cours de tour.
|
||||
pub projsnap: Option<SnapTurn>,
|
||||
pub snap_refreshed: Mutex<Option<ProjSnapshot>>,
|
||||
}
|
||||
|
||||
/// PROJ_SNAPSHOT (lot 34) : ce qu'un tour avec la clé garde pour rafraîchir
|
||||
/// le bloc projet quand une compaction réécrit l'historique en plein tour
|
||||
/// (`projSnapTurn`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SnapTurn {
|
||||
pub caps: Value,
|
||||
pub key: String,
|
||||
pub sys_hash: String,
|
||||
}
|
||||
|
||||
impl GoTurnHost {
|
||||
/// L'instantané posé en cours de tour (`takeRefreshed`), s'il y en a un.
|
||||
pub fn take_refreshed_snap(&self) -> Option<ProjSnapshot> {
|
||||
self.snap_refreshed
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
}
|
||||
}
|
||||
|
||||
/// Délai d'une demande de slot : le rechargement SLOT_PERSIST relit jusqu'à
|
||||
@@ -738,6 +762,40 @@ impl TurnHost for GoTurnHost {
|
||||
self.shared.echo_refusals.refuse(model);
|
||||
}
|
||||
|
||||
async fn project_refresh(&self, msgs: Vec<Message>) -> Vec<Message> {
|
||||
let Some(t) = &self.projsnap else {
|
||||
return msgs;
|
||||
};
|
||||
let got = self
|
||||
.shared
|
||||
.store
|
||||
.internal_post(
|
||||
"/internal/v1/turn/projsnap/refresh",
|
||||
&json!({ "caps": t.caps, "key": t.key, "sys_hash": t.sys_hash }),
|
||||
)
|
||||
.await;
|
||||
let (block, snap) = match got {
|
||||
Ok((code, v)) if code.is_success() => (
|
||||
serde_json::from_value::<Vec<Message>>(v["block"].clone()).ok(),
|
||||
serde_json::from_value::<ProjSnapshot>(v["snap"].clone()).ok(),
|
||||
),
|
||||
_ => (None, None),
|
||||
};
|
||||
// Go injoignable : le bloc figé reste, sans les mises à jour (le début
|
||||
// du prompt change de toute façon) ; l'instantané n'est pas remplacé.
|
||||
let msgs = super::compact::strip_context_updates(msgs);
|
||||
match (block, snap) {
|
||||
(Some(block), Some(snap)) => {
|
||||
*self
|
||||
.snap_refreshed
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(snap);
|
||||
super::compact::replace_project_head(msgs, &block)
|
||||
}
|
||||
_ => msgs,
|
||||
}
|
||||
}
|
||||
|
||||
fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool {
|
||||
self.perf_kind == super::perf::KIND_MAIN
|
||||
&& self.shared.prewarm.live()
|
||||
|
||||
@@ -319,10 +319,10 @@ impl ChatService {
|
||||
if conv.is_generating() {
|
||||
return Err("moteur occupé");
|
||||
}
|
||||
let (mut msgs, ctx_now, peak) = conv.with_state(|st| {
|
||||
let (mut msgs, ctx_now, peak, snap) = conv.with_state(|st| {
|
||||
let msgs = st.messages.clone();
|
||||
let used = ctx_pending(st.ctx_used, st.ctx_used_len, &msgs, &env.echo);
|
||||
(msgs, used, st.gen_peak)
|
||||
(msgs, used, st.gen_peak, st.proj_snap.clone())
|
||||
});
|
||||
// Sans message utilisateur, le contexte projet irait en tête de la
|
||||
// sentinelle : rien à préparer.
|
||||
@@ -338,13 +338,20 @@ impl ChatService {
|
||||
if compact_needed(&env.ccfg, &msgs, ctx_now, peak) {
|
||||
return Err("compaction attendue au prochain tour");
|
||||
}
|
||||
let (head, prefix, tools) = self
|
||||
.turn_view(&turn_caps)
|
||||
// Mêmes décisions que le vrai tour (PROJ_SNAPSHOT compris), sans rien
|
||||
// ranger : bloc figé gardé avec ses mises à jour, ou rafraîchi.
|
||||
let v = self
|
||||
.turn_view(&turn_caps, snap.as_ref(), true)
|
||||
.await
|
||||
.map_err(|_| "vue du tour refusée")?;
|
||||
let mut sent = head;
|
||||
sent.extend(strip_context_updates(msgs));
|
||||
let sent = inject_prefix(sent, &prefix);
|
||||
let tools = v.tools;
|
||||
let hist = match &v.snap {
|
||||
Some(d) if !d.refresh => msgs,
|
||||
_ => strip_context_updates(msgs),
|
||||
};
|
||||
let mut sent = v.head;
|
||||
sent.extend(hist);
|
||||
let sent = inject_prefix(sent, &v.prefix);
|
||||
let reasoning = turn_reasoning(&env.cfg, &self.shared.effort);
|
||||
let pol = turn_echo_policy(false, || env.echo.clone());
|
||||
self.shared.images.prefetch(&self.shared.store, &sent).await;
|
||||
|
||||
@@ -42,8 +42,9 @@
|
||||
//! préchauffage en vol le garde ([`TurnHost::prewarm_keeps`]) ; toute autre
|
||||
//! l'annule ([`super::prewarm`]).
|
||||
//!
|
||||
//! Non porté : PROJ_SNAPSHOT. Avec cette clé, le Go garde la conversation
|
||||
//! (`facadeChatBlocker`, décidé au démarrage).
|
||||
//! Bloc projet figé (PROJ_SNAPSHOT, lot 34) : une compaction ou une réduction
|
||||
//! en plein tour fait remplacer le bloc figé par le vivant
|
||||
//! ([`TurnHost::project_refresh`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
@@ -150,6 +151,13 @@ pub trait TurnHost: Send + Sync {
|
||||
body: Vec<u8>,
|
||||
keep_prewarm: bool,
|
||||
) -> impl Future<Output = Result<Reply, SendError>> + Send;
|
||||
/// PROJ_SNAPSHOT : une compaction ou une réduction vient de réécrire
|
||||
/// l'historique en plein tour — le début du prompt change de toute façon,
|
||||
/// le bloc projet figé est remplacé par le vivant et les mises à jour
|
||||
/// retirées (`projSnapTurn.refresh`). Sans la clé : tel quel.
|
||||
fn project_refresh(&self, msgs: Vec<Message>) -> impl Future<Output = Vec<Message>> + Send {
|
||||
async { msgs }
|
||||
}
|
||||
/// PREWARM : la requête qui part (empreintes cumulées de ses messages,
|
||||
/// corps) prolonge-t-elle exactement le préchauffage en vol ?
|
||||
fn prewarm_keeps(&self, _prefix: &[u64], _payload: &Value) -> bool {
|
||||
@@ -691,7 +699,7 @@ async fn run_turn_loop<H: TurnHost>(
|
||||
if let Some(c) = c {
|
||||
s.compacted_retry = true;
|
||||
log_compact(ccfg, "réactif", 0, &messages, &c, true);
|
||||
messages = c;
|
||||
messages = host.project_refresh(c).await;
|
||||
extra.clear();
|
||||
publish_history(cb, kt, &messages);
|
||||
continue;
|
||||
@@ -703,7 +711,7 @@ async fn run_turn_loop<H: TurnHost>(
|
||||
if changed {
|
||||
s.shrink_retries += 1;
|
||||
log_compact(ccfg, "réduction", actual, &messages, &c, true);
|
||||
messages = c;
|
||||
messages = host.project_refresh(c).await;
|
||||
extra.clear();
|
||||
publish_history(cb, kt, &messages);
|
||||
continue;
|
||||
@@ -1124,7 +1132,7 @@ async fn run_turn_loop<H: TurnHost>(
|
||||
);
|
||||
if let Some(c) = c {
|
||||
s.last_est = 0;
|
||||
messages = c;
|
||||
messages = host.project_refresh(c).await;
|
||||
extra.clear();
|
||||
publish_history(cb, kt, &messages);
|
||||
}
|
||||
@@ -1183,7 +1191,7 @@ async fn run_turn_loop<H: TurnHost>(
|
||||
);
|
||||
if let Some(c) = c {
|
||||
cb(StreamEvent::DropReasoning);
|
||||
messages = c;
|
||||
messages = host.project_refresh(c).await;
|
||||
extra.clear();
|
||||
publish_history(cb, kt, &messages);
|
||||
continue;
|
||||
|
||||
@@ -52,6 +52,9 @@ struct Fake {
|
||||
/// a reçu.
|
||||
prewarm_match: Mutex<bool>,
|
||||
sent_keeps: Mutex<Vec<bool>>,
|
||||
/// PROJ_SNAPSHOT : clé posée pour ce tour ; rafraîchissements demandés.
|
||||
snap_on: Mutex<bool>,
|
||||
refreshes: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
@@ -173,6 +176,13 @@ impl TurnHost for Fake {
|
||||
async fn keep_images(&self) -> Option<i64> {
|
||||
*self.keep.lock().unwrap()
|
||||
}
|
||||
async fn project_refresh(&self, mut msgs: Vec<Message>) -> Vec<Message> {
|
||||
if *self.snap_on.lock().unwrap() {
|
||||
*self.refreshes.lock().unwrap() += 1;
|
||||
msgs.insert(0, text_msg("system", "BLOC VIVANT"));
|
||||
}
|
||||
msgs
|
||||
}
|
||||
fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool {
|
||||
// Ce que l'hôte reçoit : les empreintes des messages envoyés et le
|
||||
// corps de la requête.
|
||||
@@ -1094,3 +1104,53 @@ async fn prechauffage_garde_par_la_requete_qui_le_prolonge() {
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(*host.sent_keeps.lock().unwrap(), [false]);
|
||||
}
|
||||
|
||||
/// PROJ_SNAPSHOT : une compaction ou une réduction en plein tour fait
|
||||
/// rafraîchir le bloc projet (le début du prompt change de toute façon) ;
|
||||
/// l'historique publié et la requête rejouée le portent. Le retrait d'images
|
||||
/// gardées, lui, ne touche pas au début du prompt.
|
||||
#[tokio::test]
|
||||
async fn bloc_projet_rafraichi_par_la_compaction() {
|
||||
let host = Fake::new(vec![
|
||||
Script::Status(400, "the request exceeds the available context size".into()),
|
||||
answer("après compaction"),
|
||||
]);
|
||||
*host.compacted.lock().unwrap() = Some(vec![um("résumé")]);
|
||||
*host.snap_on.lock().unwrap() = true;
|
||||
let (r, evs) = run(&host, &config(&[]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None, "{:?}", host.bodies.lock().unwrap());
|
||||
assert_eq!(*host.refreshes.lock().unwrap(), 1);
|
||||
assert!(evs.iter().any(|e| matches!(e, StreamEvent::NewHistory(h)
|
||||
if h.first().and_then(|m| m.content.as_ref()) == Some(&json!("BLOC VIVANT")))));
|
||||
assert_eq!(host.body(1)["messages"][0]["content"], "BLOC VIVANT");
|
||||
// Réduction forcée (pas de résumé possible) : rafraîchi aussi.
|
||||
let host = Fake::new(vec![
|
||||
Script::Status(
|
||||
400,
|
||||
"request (400000 tokens) exceeds the available context size (32768 tokens)".into(),
|
||||
),
|
||||
answer("ok"),
|
||||
]);
|
||||
*host.snap_on.lock().unwrap() = true;
|
||||
let hist: Vec<Message> = (0..12)
|
||||
.map(|i| um(&format!("message {i} {}", "x".repeat(400))))
|
||||
.collect();
|
||||
let (r, _) = run_hist(&host, &config(&[]), hist).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(*host.refreshes.lock().unwrap(), 1);
|
||||
assert!(
|
||||
host.phases.lock().unwrap().len() == 1,
|
||||
"résumé tenté puis réduction"
|
||||
);
|
||||
assert_eq!(host.body(1)["messages"][0]["content"], "BLOC VIVANT");
|
||||
// Images gardées retirées au filet réactif : pas de rafraîchissement.
|
||||
let host = Fake::new(vec![
|
||||
Script::Status(400, "the request exceeds the available context size".into()),
|
||||
answer_u("fini", 0),
|
||||
]);
|
||||
*host.snap_on.lock().unwrap() = true;
|
||||
let hist = vec![um("q"), tool_res("[ok]"), kept_relay(1000), um("suite")];
|
||||
let (r, _) = run_hist(&host, &config(&[]), hist).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(*host.refreshes.lock().unwrap(), 0);
|
||||
}
|
||||
@@ -23,12 +23,13 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::compact::{CompactCaps, is_loki_injected, strip_context_updates, without_ctx_update};
|
||||
use super::generate::{ChatService, TurnEnv, obj};
|
||||
use super::history::{drop_stray_nudges, history_from_view, inject_prefix, tool_used_delta};
|
||||
use super::host::{GoTurnHost, TurnCompact};
|
||||
use super::compact::{CompactCaps, is_loki_injected, without_ctx_update};
|
||||
use super::generate::{Assembled, ChatService, TurnEnv, obj};
|
||||
use super::history::{drop_stray_nudges, history_from_view, tool_used_delta};
|
||||
use super::host::{GoTurnHost, SnapTurn, TurnCompact};
|
||||
use super::lease::{EngineLease, LeaseSignal};
|
||||
use super::persist::BK_CHAT;
|
||||
use super::persist::ProjSnapshot;
|
||||
use super::turn::{Cancel, TurnConfig, run_turn};
|
||||
use super::types::{Message, ReasoningEcho, StreamEvent, Tool};
|
||||
use super::wire::{TurnReasoning, with_echo};
|
||||
@@ -166,6 +167,8 @@ struct PassOut {
|
||||
echo: Option<ReasoningEcho>,
|
||||
saw_usage: bool,
|
||||
asked: bool,
|
||||
/// Bloc projet rafraîchi en cours de passe (PROJ_SNAPSHOT).
|
||||
snap: Option<ProjSnapshot>,
|
||||
}
|
||||
|
||||
fn cap_bool(caps: &Value, k: &str) -> bool {
|
||||
@@ -304,8 +307,18 @@ impl ChatService {
|
||||
// Travail annexe : le Go efface le slot à la fin s'il a servi
|
||||
// (les requêtes de la passe le lui disent en passant).
|
||||
let side = side_job_begin(&self.shared.store).await;
|
||||
self.run_pass(pe, epoch, msgs, tools, v["caps"].clone(), true, &[], run)
|
||||
.await;
|
||||
self.run_pass(
|
||||
pe,
|
||||
epoch,
|
||||
msgs,
|
||||
tools,
|
||||
v["caps"].clone(),
|
||||
true,
|
||||
&[],
|
||||
None,
|
||||
run,
|
||||
)
|
||||
.await;
|
||||
side_job_end(&self.shared.store, side).await;
|
||||
}
|
||||
other => {
|
||||
@@ -359,18 +372,19 @@ impl ChatService {
|
||||
return false;
|
||||
};
|
||||
// Même contexte que le tour de build : sinon le début du prompt change
|
||||
// et tout le cache est à refaire.
|
||||
let (head, prefix, tools) = match self.turn_view(pe.turn_caps).await {
|
||||
Ok(v) => v,
|
||||
// et tout le cache est à refaire (PROJ_SNAPSHOT compris).
|
||||
let Assembled {
|
||||
sent,
|
||||
tools,
|
||||
hist,
|
||||
snap_turn,
|
||||
} = match self.assemble_turn(epoch, pe.turn_caps, msgs).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
conv.append_delta(epoch, obj("error", e));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let hist = strip_context_updates(msgs);
|
||||
let mut sent = head;
|
||||
sent.extend(hist.iter().cloned());
|
||||
let sent = inject_prefix(sent, &prefix);
|
||||
let out = self
|
||||
.run_pass(
|
||||
pe,
|
||||
@@ -380,6 +394,7 @@ impl ChatService {
|
||||
pe.turn_caps.clone(),
|
||||
false,
|
||||
&hist,
|
||||
snap_turn,
|
||||
run,
|
||||
)
|
||||
.await;
|
||||
@@ -387,6 +402,10 @@ impl ChatService {
|
||||
if let Some(base) = out.new_base {
|
||||
st.messages = base;
|
||||
}
|
||||
// Même règle que le tour : le bloc rafraîchi en cours de passe.
|
||||
if let Some(s) = out.snap {
|
||||
st.proj_snap = Some(s);
|
||||
}
|
||||
st.messages.extend(out.extra);
|
||||
if !out.content.trim().is_empty() {
|
||||
let a = Message {
|
||||
@@ -421,6 +440,7 @@ impl ChatService {
|
||||
caps: Value,
|
||||
isolated: bool,
|
||||
base: &[Message],
|
||||
snap_turn: Option<SnapTurn>,
|
||||
run: &mut PassRun<'_>,
|
||||
) -> PassOut {
|
||||
let conv = self.sessions.conv().clone();
|
||||
@@ -463,6 +483,8 @@ impl ChatService {
|
||||
super::perf::KIND_MAIN
|
||||
},
|
||||
side_slot: Default::default(),
|
||||
projsnap: snap_turn,
|
||||
snap_refreshed: Default::default(),
|
||||
};
|
||||
let mut out = PassOut::default();
|
||||
let mut cb = |ev: StreamEvent| -> bool {
|
||||
@@ -546,6 +568,7 @@ impl ChatService {
|
||||
}
|
||||
};
|
||||
out.extra = res.extra;
|
||||
out.snap = host.take_refreshed_snap();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,8 +247,8 @@ fn serve() -> ExitCode {
|
||||
let sup = child::Supervisor::spawn(go_bin, go_port, env);
|
||||
wait_for(backend, Duration::from_secs(30)).await;
|
||||
let engine_paths = engine::Paths::from_env();
|
||||
// Le Go peut garder la conversation malgré LOKI_CHAT=rust (réglage que
|
||||
// la façade ne sait pas encore honorer) : sa décision fait foi.
|
||||
// Le Go confirme qu'il remet la conversation (adresse et jeton bien
|
||||
// reçus) : sa réponse fait foi.
|
||||
let chat_rust = chat_rust
|
||||
&& match go_keeps_chat(backend, &token).await {
|
||||
Ok(None) => true,
|
||||
@@ -415,9 +415,8 @@ fn serve() -> ExitCode {
|
||||
}
|
||||
|
||||
/// Conversation tenue par la façade : c'est le défaut. `LOKI_CHAT=go` (ou
|
||||
/// 0/off/false/no) la rend au Go — porte de sortie. Le Go la garde de toute
|
||||
/// façon si un réglage que la façade ne sait pas honorer est posé
|
||||
/// (`facadeChatBlocker`).
|
||||
/// 0/off/false/no) la rend au Go — porte de sortie. Plus aucun réglage ne la
|
||||
/// garde au Go (lot 34).
|
||||
fn chat_held_by_facade() -> bool {
|
||||
chat_wanted(std::env::var("LOKI_CHAT").ok().as_deref())
|
||||
}
|
||||
|
||||
@@ -141,6 +141,13 @@ pub struct FakeGo {
|
||||
/// (« check », « begin {corps} », « end {corps} »).
|
||||
pub prewarm_refuse: Mutex<Option<String>>,
|
||||
pub prewarm_log: Mutex<Vec<String>>,
|
||||
/// PROJ_SNAPSHOT : corps reçus par /internal/v1/turn/view ; décisions à
|
||||
/// rendre (une par appel, dans l'ordre) ; réponse de
|
||||
/// /internal/v1/turn/projsnap/refresh et corps reçus.
|
||||
pub turn_view_bodies: Mutex<Vec<serde_json::Value>>,
|
||||
pub projsnaps: Mutex<std::collections::VecDeque<serde_json::Value>>,
|
||||
pub projsnap_refresh: Mutex<Option<serde_json::Value>>,
|
||||
pub projsnap_refreshes: Mutex<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
impl FakeGo {
|
||||
@@ -360,16 +367,31 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
}
|
||||
"/internal/v1/turn/view" => {
|
||||
st.turn_log.lock().unwrap().push("view".into());
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
st.turn_view_bodies.lock().unwrap().push(v);
|
||||
if let Some(v) = st.turn_view.lock().unwrap().clone() {
|
||||
return resp(200, v.to_string());
|
||||
}
|
||||
let out = serde_json::json!({
|
||||
let mut out = serde_json::json!({
|
||||
"head": [{"role": "system", "content": "Tu es Loki."}],
|
||||
"prefix": "PRÉAMBULE",
|
||||
"tools": [{"type": "function", "function": {"name": "bash", "description": "", "parameters": {"type": "object"}}}],
|
||||
});
|
||||
if let Some(d) = st.projsnaps.lock().unwrap().pop_front() {
|
||||
out["projsnap"] = d;
|
||||
}
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/turn/projsnap/refresh" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
st.projsnap_refreshes.lock().unwrap().push(v);
|
||||
match st.projsnap_refresh.lock().unwrap().clone() {
|
||||
Some(r) => resp(200, r.to_string()),
|
||||
None => resp(500, r#"{"error":"non prévu"}"#),
|
||||
}
|
||||
}
|
||||
"/internal/v1/chat/holder" => match st.go_keeps_chat.lock().unwrap().clone() {
|
||||
None => resp(200, r#"{"holder":"rust"}"#),
|
||||
Some(why) => resp(
|
||||
|
||||
@@ -695,6 +695,8 @@ async fn tour_complet_par_le_go() {
|
||||
queued: Box::new(Vec::new),
|
||||
perf_kind: "main",
|
||||
side_slot: Default::default(),
|
||||
projsnap: None,
|
||||
snap_refreshed: Default::default(),
|
||||
};
|
||||
let bash = Tool {
|
||||
kind: "function".into(),
|
||||
@@ -1072,6 +1074,8 @@ async fn hote_et_sonde_de_gabarit() {
|
||||
queued: Box::new(Vec::new),
|
||||
perf_kind: "main",
|
||||
side_slot: Default::default(),
|
||||
projsnap: None,
|
||||
snap_refreshed: Default::default(),
|
||||
}
|
||||
};
|
||||
let bash = Tool {
|
||||
@@ -2172,6 +2176,14 @@ async fn prechauffage_par_la_facade() {
|
||||
.iter()
|
||||
.any(|p| p["kind"] == "prewarm" && p["stats"]["prompt_tokens_total"] == 60)
|
||||
);
|
||||
// La vue du préchauffage est demandée sans rien à ranger (PROJ_SNAPSHOT).
|
||||
assert!(
|
||||
go.turn_view_bodies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|b| b["dry"] == true)
|
||||
);
|
||||
}
|
||||
|
||||
/// Le Go refuse (moteur occupé) : rien ne part ; annulé par une autre
|
||||
@@ -2215,3 +2227,160 @@ async fn prechauffage_refuse_ou_annule() {
|
||||
.all(|p| p["kind"] != "prewarm")
|
||||
);
|
||||
}
|
||||
|
||||
fn proj_snap(desc: &str, delta_tok: i64) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"at": 1,
|
||||
"msgs": [{"role": "system", "content": format!("Project context — {desc}")}],
|
||||
"state": {"desc": desc},
|
||||
"key": "k1",
|
||||
"sys_hash": "h1",
|
||||
"delta_tok": delta_tok
|
||||
})
|
||||
}
|
||||
|
||||
/// Lot 34 : PROJ_SNAPSHOT tenu par la façade. Le Go décide (instantané pris,
|
||||
/// mise à jour, rafraîchissement) ; la façade range l'instantané avec la
|
||||
/// discussion et pose la mise à jour en tête du message, dans la requête et
|
||||
/// dans l'historique.
|
||||
#[tokio::test]
|
||||
async fn bloc_projet_fige_par_la_facade() {
|
||||
use loki_rs::chat::persist::ProjSnapshot;
|
||||
let (url, seen) = fake_engine(vec![answer("un"), answer("deux"), answer("trois")]).await;
|
||||
let (svc, go, _stop, _t) = service(Some(&url)).await;
|
||||
let delta = "<context_update from=\"loki\" at=\"x\">\n+ neuf\n</context_update>\n\n";
|
||||
go.projsnaps.lock().unwrap().extend([
|
||||
serde_json::json!({"refresh": true, "snap": proj_snap("figé", 0), "key": "k1", "sys_hash": "h1"}),
|
||||
serde_json::json!({"refresh": false, "delta": delta, "snap": proj_snap("neuf", 5), "key": "k1", "sys_hash": "h1"}),
|
||||
serde_json::json!({"refresh": true, "snap": proj_snap("frais", 0), "key": "k1", "sys_hash": "h1"}),
|
||||
]);
|
||||
let stored = |svc: &ChatService| -> ProjSnapshot {
|
||||
svc.sessions
|
||||
.conv()
|
||||
.with_state(|st| st.proj_snap.clone())
|
||||
.expect("instantané rangé")
|
||||
};
|
||||
let as_snap = |v: serde_json::Value| -> ProjSnapshot { serde_json::from_value(v).unwrap() };
|
||||
|
||||
svc.send(serde_json::json!({"message": "bonjour", "cid": "s1"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
assert_eq!(stored(&svc), as_snap(proj_snap("figé", 0)));
|
||||
let views = go.turn_view_bodies.lock().unwrap().clone();
|
||||
assert_eq!(views[0]["snap"], serde_json::Value::Null);
|
||||
assert_eq!(views[0]["dry"], false);
|
||||
|
||||
// Un changement : mise à jour en tête du nouveau message, rangée telle
|
||||
// quelle (le tour suivant renvoie les mêmes octets).
|
||||
svc.send(serde_json::json!({"message": "suite", "cid": "s2"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 2).await;
|
||||
let views = go.turn_view_bodies.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
as_snap(views[1]["snap"].clone()),
|
||||
as_snap(proj_snap("figé", 0))
|
||||
);
|
||||
let sent = seen.lock().unwrap()[1].1.clone();
|
||||
let msgs = sent["messages"].as_array().unwrap();
|
||||
assert_eq!(msgs.last().unwrap()["content"], format!("{delta}suite"));
|
||||
assert!(
|
||||
msgs.last().unwrap().get("ctx_upd").is_none(),
|
||||
"marque envoyée au moteur"
|
||||
);
|
||||
let hist = svc.sessions.conv().with_state(|st| st.messages.clone());
|
||||
let upd = hist.iter().find(|m| m.ctx_upd).expect("mise à jour rangée");
|
||||
assert_eq!(
|
||||
upd.content,
|
||||
Some(serde_json::json!(format!("{delta}suite")))
|
||||
);
|
||||
assert_eq!(stored(&svc), as_snap(proj_snap("neuf", 5)));
|
||||
|
||||
// Rafraîchi : instantané neuf, mise à jour retirée de l'historique.
|
||||
svc.send(serde_json::json!({"message": "fin", "cid": "s3"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 3).await;
|
||||
let sent = seen.lock().unwrap()[2].1.clone();
|
||||
assert!(
|
||||
sent["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["content"] == "suite"),
|
||||
"{sent}"
|
||||
);
|
||||
let hist = svc.sessions.conv().with_state(|st| st.messages.clone());
|
||||
assert!(hist.iter().all(|m| !m.ctx_upd));
|
||||
assert_eq!(stored(&svc), as_snap(proj_snap("frais", 0)));
|
||||
}
|
||||
|
||||
/// Réduction forcée en plein tour : le bloc vivant remplace le figé, et
|
||||
/// devient l'instantané du tour suivant.
|
||||
#[tokio::test]
|
||||
async fn bloc_projet_rafraichi_en_plein_tour() {
|
||||
use loki_rs::chat::persist::ProjSnapshot;
|
||||
let (url, seen) = fake_engine(vec![
|
||||
"STATUS 400\nrequest (400000 tokens) exceeds the available context size (32768 tokens)".into(),
|
||||
serde_json::json!({"choices": [{"message": {"content": "résumé des échanges"}, "finish_reason": "stop"}]})
|
||||
.to_string(),
|
||||
answer("ok"),
|
||||
])
|
||||
.await;
|
||||
let (svc, go, _stop, _t) = service(Some(&url)).await;
|
||||
// Un historique assez long pour qu'il y ait de quoi résumer.
|
||||
svc.sessions.conv().with_state(|st| {
|
||||
for i in 0..8 {
|
||||
st.messages.push(loki_rs::chat::types::Message {
|
||||
role: if i % 2 == 0 { "user" } else { "assistant" }.into(),
|
||||
content: Some(serde_json::json!(format!(
|
||||
"échange {i} {}",
|
||||
"x".repeat(800)
|
||||
))),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
});
|
||||
go.projsnaps.lock().unwrap().push_back(serde_json::json!(
|
||||
{"refresh": true, "snap": proj_snap("figé", 0), "key": "k1", "sys_hash": "h1"}
|
||||
));
|
||||
*go.projsnap_refresh.lock().unwrap() = Some(serde_json::json!({
|
||||
"block": [{"role": "system", "content": "Project context — vivant"}],
|
||||
"snap": proj_snap("vivant", 0)
|
||||
}));
|
||||
*go.turn_view.lock().unwrap() = None;
|
||||
svc.send(serde_json::json!({"message": "bonjour", "cid": "m1"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
let refreshes = go.projsnap_refreshes.lock().unwrap().clone();
|
||||
assert_eq!(refreshes.len(), 1, "{refreshes:?}");
|
||||
assert_eq!(
|
||||
(
|
||||
refreshes[0]["key"].clone(),
|
||||
refreshes[0]["sys_hash"].clone()
|
||||
),
|
||||
(serde_json::json!("k1"), serde_json::json!("h1"))
|
||||
);
|
||||
let sent = seen.lock().unwrap().last().unwrap().1.clone();
|
||||
assert!(
|
||||
sent["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m["content"]
|
||||
.as_str()
|
||||
.is_some_and(|c| c.contains("Project context — vivant"))),
|
||||
"{sent}"
|
||||
);
|
||||
let stored: ProjSnapshot = svc
|
||||
.sessions
|
||||
.conv()
|
||||
.with_state(|st| st.proj_snap.clone())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stored,
|
||||
serde_json::from_value(proj_snap("vivant", 0)).unwrap()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user