mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +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_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) 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_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,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
|
// 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
|
// adresse et le jeton interne, sans lesquels rien ne peut lui être remis.
|
||||||
// aucun réglage qu'elle ne sait pas honorer (facadeChatBlocker).
|
// 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 {
|
func facadeChat() bool {
|
||||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") &&
|
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") &&
|
||||||
facadeAddr() != "" && internalToken() != "" && facadeChatDecision() == ""
|
facadeAddr() != "" && internalToken() != ""
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInternalChatHolder : qui tient la conversation ({"holder":"rust"} ou
|
// 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"})
|
sendJSON(w, http.StatusOK, map[string]any{"holder": "rust"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
why := facadeChatDecision()
|
why := "adresse de la façade ou jeton interne manquant"
|
||||||
if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") {
|
if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") {
|
||||||
why = facadeChatEnv + " absent"
|
why = facadeChatEnv + " absent"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,22 +27,8 @@ type fausseFacade struct {
|
|||||||
internalBody string
|
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 {
|
func demarrerFacade(t *testing.T) *fausseFacade {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
oublierDecisionFacade(t)
|
|
||||||
f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`}
|
f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`}
|
||||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
b, _ := io.ReadAll(r.Body)
|
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 :
|
// Plus aucun réglage ne garde la conversation ici (lot 34) : avec chacune des
|
||||||
// rien ne lui est remis, et elle l'apprend au démarrage. La décision tient
|
// clés opt-in posée, la façade la tient et le trafic de chat lui est remis.
|
||||||
// jusqu'au redémarrage.
|
// Sans LOKI_CHAT=rust, ou sans son adresse : le Go, et pourquoi.
|
||||||
func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
|
func TestFacadeAucunReglageNeGardeLaConversation(t *testing.T) {
|
||||||
testHome(t)
|
testHome(t)
|
||||||
f := demarrerFacade(t)
|
f := demarrerFacade(t)
|
||||||
t.Setenv(internalTokenEnv, jetonTest)
|
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"`) {
|
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())
|
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") }
|
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`)))
|
withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`)))
|
||||||
if rec.Body.String() != "go" || len(f.recu()) != 0 {
|
if rec.Body.String() == "go" || len(f.recu()) != 1 {
|
||||||
t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu())
|
t.Fatalf("pas remis à la façade : %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)
|
|
||||||
}
|
}
|
||||||
putStr(bkState, "retry_patterns", "")
|
putStr(bkState, "retry_patterns", "")
|
||||||
// Sans LOKI_CHAT=rust : le Go, et pourquoi.
|
t.Setenv(facadeAddrEnv, "")
|
||||||
oublierDecisionFacade(t)
|
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, "")
|
t.Setenv(facadeChatEnv, "")
|
||||||
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "LOKI_CHAT absent") {
|
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())
|
t.Fatalf("sans la variable : %s", w.Body.String())
|
||||||
@@ -1076,7 +1042,6 @@ func TestFacadeBackends(t *testing.T) {
|
|||||||
func TestFacadeSondeDeGabarit(t *testing.T) {
|
func TestFacadeSondeDeGabarit(t *testing.T) {
|
||||||
testHome(t)
|
testHome(t)
|
||||||
freshTplProbe(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"}}`
|
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) {
|
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" {
|
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)
|
handleInternalOAIServed(w, r)
|
||||||
case "/internal/v1/turn/perf":
|
case "/internal/v1/turn/perf":
|
||||||
handleInternalTurnPerf(w, r)
|
handleInternalTurnPerf(w, r)
|
||||||
|
case "/internal/v1/turn/projsnap/refresh":
|
||||||
|
handleInternalTurnProjSnapRefresh(w, r)
|
||||||
case "/internal/v1/turn/slot":
|
case "/internal/v1/turn/slot":
|
||||||
handleInternalTurnSlot(w, r)
|
handleInternalTurnSlot(w, r)
|
||||||
case "/internal/v1/prewarm/check":
|
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
|
// handleInternalTurnView : les pièces de turnView (assembleTurn) que la
|
||||||
// PROJ_SNAPSHOT) que la façade assemble autour de l'historique : head part
|
// façade assemble autour de l'historique : head part devant les messages, puis
|
||||||
// devant les messages, puis prefix (InjectSkills) fusionné dans le premier
|
// prefix (InjectSkills) fusionné dans le premier système qui n'est pas un
|
||||||
// système qui n'est pas un message projet, ou posé en tête.
|
// 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) {
|
func handleInternalTurnView(w http.ResponseWriter, r *http.Request) {
|
||||||
var in struct {
|
var in struct {
|
||||||
Caps Caps `json:"caps"`
|
Caps Caps `json:"caps"`
|
||||||
|
Snap *projSnapshot `json:"snap"`
|
||||||
|
Dry bool `json:"dry"`
|
||||||
}
|
}
|
||||||
if !readInternalJSON(w, r, &in) {
|
if !readInternalJSON(w, r, &in) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
caps := in.Caps
|
caps := in.Caps
|
||||||
if caps.Agent && projSnapEnabled(ReadConfig()) {
|
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
|
return
|
||||||
}
|
}
|
||||||
head := []Message{}
|
head := []Message{}
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ func TestTourInterneVue(t *testing.T) {
|
|||||||
if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil {
|
if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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()
|
.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 --------------------------------------------------
|
// ---- bornes et assemblage --------------------------------------------------
|
||||||
|
|
||||||
/// `compactBounds` : frontières tête/queue. Tête = messages système de tête ;
|
/// `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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 crate::chat::types::{ToolCall, ToolCallFunc, ToolFunction};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|||||||
@@ -22,8 +22,10 @@
|
|||||||
//! 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é.
|
||||||
//!
|
//!
|
||||||
//! Écart assumé (reste au Go) : PROJ_SNAPSHOT (refusé par `/turn/view`). Le
|
//! Le bloc projet figé (PROJ_SNAPSHOT, lot 34) : le Go décide avec la vue du
|
||||||
//! préchauffage (PREWARM, lot 33) part en fin de tour ([`super::prewarm`]).
|
//! 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)
|
//! Les images gardées (KEEP_TURN_IMAGES, lot 32)
|
||||||
//! partent en début et en fin de tour quand il le faut
|
//! 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
|
//! ([`ChatService::kept_images_turn_start`]). La télémétrie des complétions
|
||||||
@@ -46,18 +48,19 @@ 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, drop_kept_images, estimate_tokens, log_compact, log_ctx_estimate,
|
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::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::{
|
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::journal::Delta;
|
||||||
use super::keepimg::{has_kept_images, keep_turn_images_on};
|
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::persist::ProjSnapshot;
|
||||||
use super::sessions::{GoSessionHooks, Sessions};
|
use super::sessions::{GoSessionHooks, Sessions};
|
||||||
use super::turn::{Cancel, TurnConfig, run_turn};
|
use super::turn::{Cancel, TurnConfig, run_turn};
|
||||||
use super::types::{Message, ReasoningEcho, StreamEvent, Tool};
|
use super::types::{Message, ReasoningEcho, StreamEvent, Tool};
|
||||||
@@ -177,6 +180,42 @@ pub(super) struct TurnEnv {
|
|||||||
pub(super) summarizer: EngineSummarizer,
|
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.
|
/// Le chat tenu par la façade : la discussion ouverte, ses tours et sa file.
|
||||||
pub struct ChatService {
|
pub struct ChatService {
|
||||||
pub sessions: Arc<Sessions<GoSessionHooks>>,
|
pub sessions: Arc<Sessions<GoSessionHooks>>,
|
||||||
@@ -523,23 +562,34 @@ impl ChatService {
|
|||||||
Some(compacted)
|
Some(compacted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/internal/v1/turn/view` : tête (prompt du preset, consignes,
|
/// Les pièces de la vue d'envoi (`/internal/v1/turn/view`) : tête du Go
|
||||||
/// projet), préambule et outils du tour ; `Err` : le message à journaliser.
|
/// (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(
|
pub(super) async fn turn_view(
|
||||||
&self,
|
&self,
|
||||||
caps: &Value,
|
caps: &Value,
|
||||||
) -> Result<(Vec<Message>, String, Vec<Tool>), String> {
|
snap: Option<&ProjSnapshot>,
|
||||||
|
dry: bool,
|
||||||
|
) -> Result<ViewParts, String> {
|
||||||
match self
|
match self
|
||||||
.shared
|
.shared
|
||||||
.store
|
.store
|
||||||
.internal_post("/internal/v1/turn/view", &json!({ "caps": caps }))
|
.internal_post(
|
||||||
|
"/internal/v1/turn/view",
|
||||||
|
&json!({ "caps": caps, "snap": snap, "dry": dry }),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((s, v)) if s.as_u16() == 200 => Ok((
|
Ok((s, v)) if s.as_u16() == 200 => Ok(ViewParts {
|
||||||
serde_json::from_value::<Vec<Message>>(v["head"].clone()).unwrap_or_default(),
|
head: serde_json::from_value::<Vec<Message>>(v["head"].clone()).unwrap_or_default(),
|
||||||
v["prefix"].as_str().unwrap_or("").to_string(),
|
prefix: v["prefix"].as_str().unwrap_or("").to_string(),
|
||||||
serde_json::from_value::<Vec<Tool>>(v["tools"].clone()).unwrap_or_default(),
|
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"]
|
Ok((_, v)) => Err(v["error"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("vue du tour refusée")
|
.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).
|
/// Configuration, point de chat et résumeur d'un tour (lus une fois).
|
||||||
pub(super) async fn turn_env(&self) -> TurnEnv {
|
pub(super) async fn turn_env(&self) -> TurnEnv {
|
||||||
let store = &self.shared.store;
|
let store = &self.shared.store;
|
||||||
@@ -827,30 +961,21 @@ impl ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vue d'envoi : tête du Go (prompt du preset, consignes, projet),
|
// Vue d'envoi : tête du Go (prompt du preset, consignes, projet ou
|
||||||
// l'historique sans bloc <context_update>, le préambule.
|
// bloc figé), l'historique, le préambule.
|
||||||
let (head, prefix, tools) = match self.turn_view(&prep.turn_caps).await {
|
let Assembled {
|
||||||
Ok(v) => v,
|
sent,
|
||||||
|
tools,
|
||||||
|
hist,
|
||||||
|
snap_turn,
|
||||||
|
} = match self.assemble_turn(epoch, &prep.turn_caps, msgs).await {
|
||||||
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
conv.append_delta(epoch, obj("error", e));
|
conv.append_delta(epoch, obj("error", e));
|
||||||
self.finish(epoch, &cancel, lease, turn_start).await;
|
self.finish(epoch, &cancel, lease, turn_start).await;
|
||||||
return;
|
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 {
|
let temperature = if prep.temperature == 0.0 {
|
||||||
0.7
|
0.7
|
||||||
@@ -889,6 +1014,8 @@ impl ChatService {
|
|||||||
queued: Box::new(move || me.drain_queued(epoch)),
|
queued: Box::new(move || me.drain_queued(epoch)),
|
||||||
perf_kind: super::perf::KIND_MAIN,
|
perf_kind: super::perf::KIND_MAIN,
|
||||||
side_slot: Default::default(),
|
side_slot: Default::default(),
|
||||||
|
projsnap: snap_turn,
|
||||||
|
snap_refreshed: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chaque événement devient un delta du journal.
|
// 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);
|
drop(host);
|
||||||
|
|
||||||
// Vue modèle rangée : outils puis réponse finale — sauf si un reset
|
// 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() {
|
if let Some(base) = new_base.take() {
|
||||||
st.messages = base;
|
st.messages = base;
|
||||||
}
|
}
|
||||||
|
if let Some(s) = refreshed {
|
||||||
|
st.proj_snap = Some(s);
|
||||||
|
}
|
||||||
st.messages.extend(res.extra.iter().cloned());
|
st.messages.extend(res.extra.iter().cloned());
|
||||||
if !content.trim().is_empty() {
|
if !content.trim().is_empty() {
|
||||||
let a = Message {
|
let a = Message {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use super::compact::{
|
|||||||
parse_summary_response,
|
parse_summary_response,
|
||||||
};
|
};
|
||||||
use super::engine::{EngineHttp, classify, friendly_error};
|
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::stream::{CacheHintFn, PreflightFn, RetryPatterns};
|
||||||
use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost};
|
use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost};
|
||||||
use super::types::{AskEvent, DiffLine, Message, Tool};
|
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`,
|
/// La requête qui part va sur le second slot (posé par `engine_slot`,
|
||||||
/// lu par `send` pour le ticket « en vol »).
|
/// lu par `send` pour le ticket « en vol »).
|
||||||
pub side_slot: AtomicBool,
|
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'à
|
/// 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);
|
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 {
|
fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool {
|
||||||
self.perf_kind == super::perf::KIND_MAIN
|
self.perf_kind == super::perf::KIND_MAIN
|
||||||
&& self.shared.prewarm.live()
|
&& self.shared.prewarm.live()
|
||||||
|
|||||||
@@ -319,10 +319,10 @@ impl ChatService {
|
|||||||
if conv.is_generating() {
|
if conv.is_generating() {
|
||||||
return Err("moteur occupé");
|
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 msgs = st.messages.clone();
|
||||||
let used = ctx_pending(st.ctx_used, st.ctx_used_len, &msgs, &env.echo);
|
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
|
// Sans message utilisateur, le contexte projet irait en tête de la
|
||||||
// sentinelle : rien à préparer.
|
// sentinelle : rien à préparer.
|
||||||
@@ -338,13 +338,20 @@ impl ChatService {
|
|||||||
if compact_needed(&env.ccfg, &msgs, ctx_now, peak) {
|
if compact_needed(&env.ccfg, &msgs, ctx_now, peak) {
|
||||||
return Err("compaction attendue au prochain tour");
|
return Err("compaction attendue au prochain tour");
|
||||||
}
|
}
|
||||||
let (head, prefix, tools) = self
|
// Mêmes décisions que le vrai tour (PROJ_SNAPSHOT compris), sans rien
|
||||||
.turn_view(&turn_caps)
|
// ranger : bloc figé gardé avec ses mises à jour, ou rafraîchi.
|
||||||
|
let v = self
|
||||||
|
.turn_view(&turn_caps, snap.as_ref(), true)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| "vue du tour refusée")?;
|
.map_err(|_| "vue du tour refusée")?;
|
||||||
let mut sent = head;
|
let tools = v.tools;
|
||||||
sent.extend(strip_context_updates(msgs));
|
let hist = match &v.snap {
|
||||||
let sent = inject_prefix(sent, &prefix);
|
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 reasoning = turn_reasoning(&env.cfg, &self.shared.effort);
|
||||||
let pol = turn_echo_policy(false, || env.echo.clone());
|
let pol = turn_echo_policy(false, || env.echo.clone());
|
||||||
self.shared.images.prefetch(&self.shared.store, &sent).await;
|
self.shared.images.prefetch(&self.shared.store, &sent).await;
|
||||||
|
|||||||
@@ -42,8 +42,9 @@
|
|||||||
//! préchauffage en vol le garde ([`TurnHost::prewarm_keeps`]) ; toute autre
|
//! préchauffage en vol le garde ([`TurnHost::prewarm_keeps`]) ; toute autre
|
||||||
//! l'annule ([`super::prewarm`]).
|
//! l'annule ([`super::prewarm`]).
|
||||||
//!
|
//!
|
||||||
//! Non porté : PROJ_SNAPSHOT. Avec cette clé, le Go garde la conversation
|
//! Bloc projet figé (PROJ_SNAPSHOT, lot 34) : une compaction ou une réduction
|
||||||
//! (`facadeChatBlocker`, décidé au démarrage).
|
//! en plein tour fait remplacer le bloc figé par le vivant
|
||||||
|
//! ([`TurnHost::project_refresh`]).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -150,6 +151,13 @@ pub trait TurnHost: Send + Sync {
|
|||||||
body: Vec<u8>,
|
body: Vec<u8>,
|
||||||
keep_prewarm: bool,
|
keep_prewarm: bool,
|
||||||
) -> impl Future<Output = Result<Reply, SendError>> + Send;
|
) -> 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,
|
/// PREWARM : la requête qui part (empreintes cumulées de ses messages,
|
||||||
/// corps) prolonge-t-elle exactement le préchauffage en vol ?
|
/// corps) prolonge-t-elle exactement le préchauffage en vol ?
|
||||||
fn prewarm_keeps(&self, _prefix: &[u64], _payload: &Value) -> bool {
|
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 {
|
if let Some(c) = c {
|
||||||
s.compacted_retry = true;
|
s.compacted_retry = true;
|
||||||
log_compact(ccfg, "réactif", 0, &messages, &c, true);
|
log_compact(ccfg, "réactif", 0, &messages, &c, true);
|
||||||
messages = c;
|
messages = host.project_refresh(c).await;
|
||||||
extra.clear();
|
extra.clear();
|
||||||
publish_history(cb, kt, &messages);
|
publish_history(cb, kt, &messages);
|
||||||
continue;
|
continue;
|
||||||
@@ -703,7 +711,7 @@ async fn run_turn_loop<H: TurnHost>(
|
|||||||
if changed {
|
if changed {
|
||||||
s.shrink_retries += 1;
|
s.shrink_retries += 1;
|
||||||
log_compact(ccfg, "réduction", actual, &messages, &c, true);
|
log_compact(ccfg, "réduction", actual, &messages, &c, true);
|
||||||
messages = c;
|
messages = host.project_refresh(c).await;
|
||||||
extra.clear();
|
extra.clear();
|
||||||
publish_history(cb, kt, &messages);
|
publish_history(cb, kt, &messages);
|
||||||
continue;
|
continue;
|
||||||
@@ -1124,7 +1132,7 @@ async fn run_turn_loop<H: TurnHost>(
|
|||||||
);
|
);
|
||||||
if let Some(c) = c {
|
if let Some(c) = c {
|
||||||
s.last_est = 0;
|
s.last_est = 0;
|
||||||
messages = c;
|
messages = host.project_refresh(c).await;
|
||||||
extra.clear();
|
extra.clear();
|
||||||
publish_history(cb, kt, &messages);
|
publish_history(cb, kt, &messages);
|
||||||
}
|
}
|
||||||
@@ -1183,7 +1191,7 @@ async fn run_turn_loop<H: TurnHost>(
|
|||||||
);
|
);
|
||||||
if let Some(c) = c {
|
if let Some(c) = c {
|
||||||
cb(StreamEvent::DropReasoning);
|
cb(StreamEvent::DropReasoning);
|
||||||
messages = c;
|
messages = host.project_refresh(c).await;
|
||||||
extra.clear();
|
extra.clear();
|
||||||
publish_history(cb, kt, &messages);
|
publish_history(cb, kt, &messages);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ struct Fake {
|
|||||||
/// a reçu.
|
/// a reçu.
|
||||||
prewarm_match: Mutex<bool>,
|
prewarm_match: Mutex<bool>,
|
||||||
sent_keeps: Mutex<Vec<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 {
|
impl Fake {
|
||||||
@@ -173,6 +176,13 @@ impl TurnHost for Fake {
|
|||||||
async fn keep_images(&self) -> Option<i64> {
|
async fn keep_images(&self) -> Option<i64> {
|
||||||
*self.keep.lock().unwrap()
|
*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 {
|
fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool {
|
||||||
// Ce que l'hôte reçoit : les empreintes des messages envoyés et le
|
// Ce que l'hôte reçoit : les empreintes des messages envoyés et le
|
||||||
// corps de la requête.
|
// 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!(r.err, None);
|
||||||
assert_eq!(*host.sent_keeps.lock().unwrap(), [false]);
|
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 serde_json::{Value, json};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
use super::compact::{CompactCaps, is_loki_injected, strip_context_updates, without_ctx_update};
|
use super::compact::{CompactCaps, is_loki_injected, without_ctx_update};
|
||||||
use super::generate::{ChatService, TurnEnv, obj};
|
use super::generate::{Assembled, ChatService, TurnEnv, obj};
|
||||||
use super::history::{drop_stray_nudges, history_from_view, inject_prefix, tool_used_delta};
|
use super::history::{drop_stray_nudges, history_from_view, tool_used_delta};
|
||||||
use super::host::{GoTurnHost, TurnCompact};
|
use super::host::{GoTurnHost, SnapTurn, TurnCompact};
|
||||||
use super::lease::{EngineLease, LeaseSignal};
|
use super::lease::{EngineLease, LeaseSignal};
|
||||||
use super::persist::BK_CHAT;
|
use super::persist::BK_CHAT;
|
||||||
|
use super::persist::ProjSnapshot;
|
||||||
use super::turn::{Cancel, TurnConfig, run_turn};
|
use super::turn::{Cancel, TurnConfig, run_turn};
|
||||||
use super::types::{Message, ReasoningEcho, StreamEvent, Tool};
|
use super::types::{Message, ReasoningEcho, StreamEvent, Tool};
|
||||||
use super::wire::{TurnReasoning, with_echo};
|
use super::wire::{TurnReasoning, with_echo};
|
||||||
@@ -166,6 +167,8 @@ struct PassOut {
|
|||||||
echo: Option<ReasoningEcho>,
|
echo: Option<ReasoningEcho>,
|
||||||
saw_usage: bool,
|
saw_usage: bool,
|
||||||
asked: bool,
|
asked: bool,
|
||||||
|
/// Bloc projet rafraîchi en cours de passe (PROJ_SNAPSHOT).
|
||||||
|
snap: Option<ProjSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cap_bool(caps: &Value, k: &str) -> bool {
|
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
|
// Travail annexe : le Go efface le slot à la fin s'il a servi
|
||||||
// (les requêtes de la passe le lui disent en passant).
|
// (les requêtes de la passe le lui disent en passant).
|
||||||
let side = side_job_begin(&self.shared.store).await;
|
let side = side_job_begin(&self.shared.store).await;
|
||||||
self.run_pass(pe, epoch, msgs, tools, v["caps"].clone(), true, &[], run)
|
self.run_pass(
|
||||||
.await;
|
pe,
|
||||||
|
epoch,
|
||||||
|
msgs,
|
||||||
|
tools,
|
||||||
|
v["caps"].clone(),
|
||||||
|
true,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
run,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
side_job_end(&self.shared.store, side).await;
|
side_job_end(&self.shared.store, side).await;
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
@@ -359,18 +372,19 @@ impl ChatService {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
// Même contexte que le tour de build : sinon le début du prompt change
|
// Même contexte que le tour de build : sinon le début du prompt change
|
||||||
// et tout le cache est à refaire.
|
// et tout le cache est à refaire (PROJ_SNAPSHOT compris).
|
||||||
let (head, prefix, tools) = match self.turn_view(pe.turn_caps).await {
|
let Assembled {
|
||||||
Ok(v) => v,
|
sent,
|
||||||
|
tools,
|
||||||
|
hist,
|
||||||
|
snap_turn,
|
||||||
|
} = match self.assemble_turn(epoch, pe.turn_caps, msgs).await {
|
||||||
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
conv.append_delta(epoch, obj("error", e));
|
conv.append_delta(epoch, obj("error", e));
|
||||||
return false;
|
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
|
let out = self
|
||||||
.run_pass(
|
.run_pass(
|
||||||
pe,
|
pe,
|
||||||
@@ -380,6 +394,7 @@ impl ChatService {
|
|||||||
pe.turn_caps.clone(),
|
pe.turn_caps.clone(),
|
||||||
false,
|
false,
|
||||||
&hist,
|
&hist,
|
||||||
|
snap_turn,
|
||||||
run,
|
run,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -387,6 +402,10 @@ impl ChatService {
|
|||||||
if let Some(base) = out.new_base {
|
if let Some(base) = out.new_base {
|
||||||
st.messages = 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);
|
st.messages.extend(out.extra);
|
||||||
if !out.content.trim().is_empty() {
|
if !out.content.trim().is_empty() {
|
||||||
let a = Message {
|
let a = Message {
|
||||||
@@ -421,6 +440,7 @@ impl ChatService {
|
|||||||
caps: Value,
|
caps: Value,
|
||||||
isolated: bool,
|
isolated: bool,
|
||||||
base: &[Message],
|
base: &[Message],
|
||||||
|
snap_turn: Option<SnapTurn>,
|
||||||
run: &mut PassRun<'_>,
|
run: &mut PassRun<'_>,
|
||||||
) -> PassOut {
|
) -> PassOut {
|
||||||
let conv = self.sessions.conv().clone();
|
let conv = self.sessions.conv().clone();
|
||||||
@@ -463,6 +483,8 @@ impl ChatService {
|
|||||||
super::perf::KIND_MAIN
|
super::perf::KIND_MAIN
|
||||||
},
|
},
|
||||||
side_slot: Default::default(),
|
side_slot: Default::default(),
|
||||||
|
projsnap: snap_turn,
|
||||||
|
snap_refreshed: Default::default(),
|
||||||
};
|
};
|
||||||
let mut out = PassOut::default();
|
let mut out = PassOut::default();
|
||||||
let mut cb = |ev: StreamEvent| -> bool {
|
let mut cb = |ev: StreamEvent| -> bool {
|
||||||
@@ -546,6 +568,7 @@ impl ChatService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
out.extra = res.extra;
|
out.extra = res.extra;
|
||||||
|
out.snap = host.take_refreshed_snap();
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,8 +247,8 @@ fn serve() -> ExitCode {
|
|||||||
let sup = child::Supervisor::spawn(go_bin, go_port, env);
|
let sup = child::Supervisor::spawn(go_bin, go_port, env);
|
||||||
wait_for(backend, Duration::from_secs(30)).await;
|
wait_for(backend, Duration::from_secs(30)).await;
|
||||||
let engine_paths = engine::Paths::from_env();
|
let engine_paths = engine::Paths::from_env();
|
||||||
// Le Go peut garder la conversation malgré LOKI_CHAT=rust (réglage que
|
// Le Go confirme qu'il remet la conversation (adresse et jeton bien
|
||||||
// la façade ne sait pas encore honorer) : sa décision fait foi.
|
// reçus) : sa réponse fait foi.
|
||||||
let chat_rust = chat_rust
|
let chat_rust = chat_rust
|
||||||
&& match go_keeps_chat(backend, &token).await {
|
&& match go_keeps_chat(backend, &token).await {
|
||||||
Ok(None) => true,
|
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
|
/// 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
|
/// 0/off/false/no) la rend au Go — porte de sortie. Plus aucun réglage ne la
|
||||||
/// façon si un réglage que la façade ne sait pas honorer est posé
|
/// garde au Go (lot 34).
|
||||||
/// (`facadeChatBlocker`).
|
|
||||||
fn chat_held_by_facade() -> bool {
|
fn chat_held_by_facade() -> bool {
|
||||||
chat_wanted(std::env::var("LOKI_CHAT").ok().as_deref())
|
chat_wanted(std::env::var("LOKI_CHAT").ok().as_deref())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ pub struct FakeGo {
|
|||||||
/// (« check », « begin {corps} », « end {corps} »).
|
/// (« check », « begin {corps} », « end {corps} »).
|
||||||
pub prewarm_refuse: Mutex<Option<String>>,
|
pub prewarm_refuse: Mutex<Option<String>>,
|
||||||
pub prewarm_log: Mutex<Vec<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 {
|
impl FakeGo {
|
||||||
@@ -360,16 +367,31 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
|||||||
}
|
}
|
||||||
"/internal/v1/turn/view" => {
|
"/internal/v1/turn/view" => {
|
||||||
st.turn_log.lock().unwrap().push("view".into());
|
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() {
|
if let Some(v) = st.turn_view.lock().unwrap().clone() {
|
||||||
return resp(200, v.to_string());
|
return resp(200, v.to_string());
|
||||||
}
|
}
|
||||||
let out = serde_json::json!({
|
let mut out = serde_json::json!({
|
||||||
"head": [{"role": "system", "content": "Tu es Loki."}],
|
"head": [{"role": "system", "content": "Tu es Loki."}],
|
||||||
"prefix": "PRÉAMBULE",
|
"prefix": "PRÉAMBULE",
|
||||||
"tools": [{"type": "function", "function": {"name": "bash", "description": "", "parameters": {"type": "object"}}}],
|
"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())
|
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() {
|
"/internal/v1/chat/holder" => match st.go_keeps_chat.lock().unwrap().clone() {
|
||||||
None => resp(200, r#"{"holder":"rust"}"#),
|
None => resp(200, r#"{"holder":"rust"}"#),
|
||||||
Some(why) => resp(
|
Some(why) => resp(
|
||||||
|
|||||||
@@ -695,6 +695,8 @@ async fn tour_complet_par_le_go() {
|
|||||||
queued: Box::new(Vec::new),
|
queued: Box::new(Vec::new),
|
||||||
perf_kind: "main",
|
perf_kind: "main",
|
||||||
side_slot: Default::default(),
|
side_slot: Default::default(),
|
||||||
|
projsnap: None,
|
||||||
|
snap_refreshed: Default::default(),
|
||||||
};
|
};
|
||||||
let bash = Tool {
|
let bash = Tool {
|
||||||
kind: "function".into(),
|
kind: "function".into(),
|
||||||
@@ -1072,6 +1074,8 @@ async fn hote_et_sonde_de_gabarit() {
|
|||||||
queued: Box::new(Vec::new),
|
queued: Box::new(Vec::new),
|
||||||
perf_kind: "main",
|
perf_kind: "main",
|
||||||
side_slot: Default::default(),
|
side_slot: Default::default(),
|
||||||
|
projsnap: None,
|
||||||
|
snap_refreshed: Default::default(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let bash = Tool {
|
let bash = Tool {
|
||||||
@@ -2172,6 +2176,14 @@ async fn prechauffage_par_la_facade() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|p| p["kind"] == "prewarm" && p["stats"]["prompt_tokens_total"] == 60)
|
.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
|
/// 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")
|
.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