mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
- LSP (lsp.go) : un signal resté dans le tampon par une republication tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même que le serveur ait vu la nouvelle version. Le canal est vidé, une publication d'une version antérieure est ignorée, et check attend 350 ms de calme après la dernière publication (liste vide puis analyse : c'est l'analyse qui compte), toujours borné par lspDiagWait. Au passage : interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par check) — dead devient atomique. - run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui l'effaçait (AJEAN 7403a8a0). - Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par toute écriture du bucket d'état, par la date/taille du fichier (autre process) et au bout d'une seconde. - MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil, plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8). - Recall : les blocs d'une compaction s'archivent en une seule transaction au lieu d'une par bloc (AJEAN #130). - Compactage en cours de tour : après un essai qui n'a rien réduit, pas de nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN 68659e73). - Motifs de relance : un motif vide (qui correspondait à tout) est écarté, une surcharge sans motif utilisable retombe sur l'embarqué, et un changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869, c52c5bf7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
9.9 KiB
Go
268 lines
9.9 KiB
Go
package loki
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// bigBlock fabrique un contenu franchement au-dessus de recallArchiveMinLen,
|
|
// pour qu'il soit archivé plutôt que simplement porté par le résumé.
|
|
func bigBlock(marker string) string {
|
|
return marker + "\n" + strings.Repeat("ligne de contenu volumineux à conserver verbatim\n", 40)
|
|
}
|
|
|
|
// Un bloc archivé doit revenir MOT POUR MOT : c'est toute la promesse de recall.
|
|
// Un résumé approximatif suffisait déjà avant ; ce qu'on ajoute ici, c'est
|
|
// l'exactitude — un gros code ou un diff ne survit pas à une reformulation.
|
|
func TestRecallRendVerbatim(t *testing.T) {
|
|
t.Setenv("LOKI_HOME", t.TempDir())
|
|
|
|
content := bigBlock("MARQUEUR-UNIQUE-42")
|
|
id, err := archiveRecallBlock("web_read: page", "tool", content)
|
|
if err != nil {
|
|
t.Fatalf("archivage impossible : %v", err)
|
|
}
|
|
if !strings.HasPrefix(id, "r") {
|
|
t.Fatalf("id mal formé : %q", id)
|
|
}
|
|
blk, ok := recallGet(id)
|
|
if !ok {
|
|
t.Fatalf("bloc %q introuvable juste après son archivage", id)
|
|
}
|
|
if blk.Content != content {
|
|
t.Fatal("le contenu rappelé diffère de l'original : l'archive n'est pas verbatim")
|
|
}
|
|
// Le passage par l'outil (ce que voit le modèle) doit aussi porter le contenu.
|
|
if got := toolRecall(map[string]any{"id": id}); !strings.Contains(got, "MARQUEUR-UNIQUE-42") {
|
|
t.Fatalf("l'outil recall ne rend pas le bloc : %q", got)
|
|
}
|
|
}
|
|
|
|
// Les ids doivent être uniques et monotones : deux compactages qui se croisent ne
|
|
// doivent jamais écraser le bloc de l'autre. NextSequence le garantit côté bbolt ;
|
|
// ce test verrouille qu'on s'en sert bien.
|
|
func TestRecallIDsUniquesEtMonotones(t *testing.T) {
|
|
t.Setenv("LOKI_HOME", t.TempDir())
|
|
|
|
vus := map[string]bool{}
|
|
var last uint64
|
|
for i := 0; i < 5; i++ {
|
|
id, err := archiveRecallBlock("bloc", "tool", bigBlock("contenu"))
|
|
if err != nil {
|
|
t.Fatalf("archivage %d : %v", i, err)
|
|
}
|
|
if vus[id] {
|
|
t.Fatalf("id %q réutilisé : un bloc en écrase un autre", id)
|
|
}
|
|
vus[id] = true
|
|
seq, ok := recallIDToSeq(id)
|
|
if !ok {
|
|
t.Fatalf("id illisible : %q", id)
|
|
}
|
|
if i > 0 && seq <= last {
|
|
t.Fatalf("séquence non monotone : %d après %d", seq, last)
|
|
}
|
|
last = seq
|
|
}
|
|
}
|
|
|
|
// recallIDToSeq doit tolérer ce que le modèle écrit réellement : avec ou sans
|
|
// préfixe, majuscule, espaces. Un id refusé pour un espace en trop enverrait le
|
|
// modèle croire que son bloc a disparu.
|
|
func TestRecallIDToSeqTolerant(t *testing.T) {
|
|
for _, in := range []string{"r7", "R7", " r7 ", "7"} {
|
|
if seq, ok := recallIDToSeq(in); !ok || seq != 7 {
|
|
t.Fatalf("%q → (%d, %v), attendu (7, true)", in, seq, ok)
|
|
}
|
|
}
|
|
for _, in := range []string{"", "rr", "abc", "r-1"} {
|
|
if _, ok := recallIDToSeq(in); ok {
|
|
t.Fatalf("%q accepté alors qu'il est invalide", in)
|
|
}
|
|
}
|
|
}
|
|
|
|
// recall_search est l'issue de secours quand l'id n'est plus dans le résumé
|
|
// courant : le bloc doit rester trouvable par mots-clés, et l'outil doit rendre
|
|
// son id pour que le modèle puisse enchaîner sur recall(id).
|
|
func TestRecallSearchRetrouveParMotsCles(t *testing.T) {
|
|
t.Setenv("LOKI_HOME", t.TempDir())
|
|
|
|
if _, err := archiveRecallBlock("web_read: recette", "tool", bigBlock("gratin dauphinois")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wanted, err := archiveRecallBlock("web_read: horaires", "tool", bigBlock("le train de 14h12 part quai 3"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hits := recallSearch("train horaires", 0)
|
|
if len(hits) == 0 {
|
|
t.Fatal("aucun résultat pour des mots-clés pourtant présents")
|
|
}
|
|
if hits[0].ID != wanted {
|
|
t.Fatalf("meilleur résultat %q, attendu %q (le libellé doit compter double)", hits[0].ID, wanted)
|
|
}
|
|
out := toolRecallSearch(map[string]any{"query": "train horaires"})
|
|
if !strings.Contains(out, wanted) {
|
|
t.Fatalf("l'outil recall_search ne rend pas l'id à rappeler : %q", out)
|
|
}
|
|
if n := len(recallSearch("motabsolumentabsent", 0)); n != 0 {
|
|
t.Fatalf("%d bloc(s) remontent pour un terme absent", n)
|
|
}
|
|
}
|
|
|
|
// Le cœur du lot : un gros bloc du torse doit être ARCHIVÉ avant compaction, et
|
|
// l'historique compacté doit porter son id. Sans llama-server en test, le résumé
|
|
// échoue et on tombe sur le repli dégraissé — c'est justement le chemin où
|
|
// l'ancien code effaçait le contenu sans laisser d'adresse.
|
|
func TestCompactArchiveLesGrosBlocs(t *testing.T) {
|
|
t.Setenv("LOKI_HOME", t.TempDir())
|
|
|
|
page := func(n int) Message {
|
|
return tm(bigBlock("page numéro " + string(rune('a'+n))))
|
|
}
|
|
msgs := []Message{um("cherche les horaires du train pour Lyon")}
|
|
for i := 0; i < 10; i++ {
|
|
msgs = append(msgs, atc("web_read"), page(i))
|
|
}
|
|
// caps.Agent : sans le mode agent, le modèle n'aurait pas l'outil recall et on
|
|
// n'archive délibérément rien.
|
|
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true})
|
|
if !changed {
|
|
t.Fatal("aucune compaction alors que le torse est plein de grosses pages")
|
|
}
|
|
joined := ""
|
|
for _, m := range out {
|
|
joined += msgText(m) + "\n"
|
|
}
|
|
if !strings.Contains(joined, "recall(") {
|
|
t.Fatalf("l'historique compacté ne cite aucun bloc rappelable :\n%s", joined)
|
|
}
|
|
if strings.Contains(joined, compactPrunedMarker) {
|
|
t.Fatal("un gros bloc a été effacé sans adresse de rappel alors qu'il était archivable")
|
|
}
|
|
// Et le contenu doit être réellement récupérable, pas seulement cité.
|
|
hits := recallSearch("page numéro", 0)
|
|
if len(hits) == 0 {
|
|
t.Fatal("les blocs cités dans l'historique ne sont pas dans l'archive")
|
|
}
|
|
if !strings.Contains(hits[0].Content, "ligne de contenu volumineux") {
|
|
t.Fatal("le bloc archivé ne contient pas le texte d'origine")
|
|
}
|
|
}
|
|
|
|
// Sans mode agent, le modèle n'a pas l'outil recall : archiver produirait des ids
|
|
// qu'il ne peut pas utiliser, et un résumé pollué de références mortes.
|
|
func TestCompactNArchivePasSansAgent(t *testing.T) {
|
|
t.Setenv("LOKI_HOME", t.TempDir())
|
|
|
|
msgs := []Message{um("question")}
|
|
for i := 0; i < 10; i++ {
|
|
msgs = append(msgs, atc("web_read"), tm(bigBlock("page")))
|
|
}
|
|
out, _ := compactMessages(t.Context(), msgs, Caps{})
|
|
for _, m := range out {
|
|
if strings.Contains(msgText(m), "recall(") {
|
|
t.Fatal("des références recall sont proposées alors que l'outil n'est pas fourni")
|
|
}
|
|
}
|
|
if len(recallSearch("page", 0)) != 0 {
|
|
t.Fatal("des blocs ont été archivés hors mode agent")
|
|
}
|
|
}
|
|
|
|
// recallEligible décide ce qui mérite un id. Deux pièges : les petits blocs (le
|
|
// résumé les porte très bien) et le résumé d'une compaction PRÉCÉDENTE, qu'on
|
|
// ré-archiverait à chaque passe en faisant enfler l'archive pour rien.
|
|
func TestRecallEligible(t *testing.T) {
|
|
gros := strings.Repeat("x", recallArchiveMinLen+1)
|
|
if !recallEligible(tm(gros)) {
|
|
t.Fatal("un gros résultat d'outil doit être archivé")
|
|
}
|
|
if recallEligible(tm("court")) {
|
|
t.Fatal("un petit bloc ne mérite pas un id : le résumé le porte")
|
|
}
|
|
if recallEligible(Message{Role: "system", Content: gros}) {
|
|
t.Fatal("un message système n'a rien à faire dans l'archive")
|
|
}
|
|
if recallEligible(um(compactSummaryPrefix + " " + gros)) {
|
|
t.Fatal("le résumé d'une compaction précédente serait ré-archivé à chaque passe")
|
|
}
|
|
}
|
|
|
|
// Garde-fou anti-résumé-dégénéré : un petit modèle mal aiguillé répond parfois
|
|
// par un simple « recall:r7 » au lieu d'écrire de la prose. Accepter ça, c'est
|
|
// remplacer tout le passé de la conversation par une référence vide.
|
|
func TestSummaryLooksEmpty(t *testing.T) {
|
|
vrai := "L'utilisateur cherche les horaires du train pour Lyon ; le train de 14h12 part quai 3, reste à confirmer le tarif."
|
|
if summaryLooksEmpty(vrai) {
|
|
t.Fatalf("un vrai résumé est rejeté : %q", vrai)
|
|
}
|
|
for _, degenere := range []string{"", " ", "recall:r7", "recall:r7 recall:r8 recall:r9", "voir recall(r7)."} {
|
|
if !summaryLooksEmpty(degenere) {
|
|
t.Fatalf("résumé dégénéré accepté : %q", degenere)
|
|
}
|
|
}
|
|
}
|
|
|
|
// compactSummaryUserMsg porte l'index des ids rappelables. Il doit rester borné
|
|
// à la passe courante : sinon une conversation infinie ferait enfler cette
|
|
// section jusqu'à annuler le bénéfice de la compaction.
|
|
func TestCompactSummaryUserMsg(t *testing.T) {
|
|
sans := compactSummaryUserMsg("résumé quelconque", nil)
|
|
if !strings.HasPrefix(sans, compactSummaryPrefix) {
|
|
t.Fatal("le message de compaction doit être reconnaissable à son préfixe")
|
|
}
|
|
if strings.Contains(sans, "recall(") {
|
|
t.Fatal("recall est proposé alors qu'aucun bloc n'a été archivé")
|
|
}
|
|
avec := compactSummaryUserMsg("résumé quelconque", []recallEntry{{id: "r7", label: "web_read: horaires"}})
|
|
if !strings.Contains(avec, "r7") || !strings.Contains(avec, "web_read: horaires") {
|
|
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
|
|
}
|
|
}
|
|
|
|
// Les blocs d'une compaction s'écrivent en une seule transaction : ids dans
|
|
// l'ordre des blocs, croissants, chacun relisible mot pour mot.
|
|
func TestArchiveRecallBlocksBatch(t *testing.T) {
|
|
testHome(t)
|
|
if e, err := archiveRecallBlocks(nil); err != nil || e != nil {
|
|
t.Fatalf("lot vide : %v, %v", e, err)
|
|
}
|
|
in := []recallArchiveInput{
|
|
{Label: "un", Role: "tool", Content: bigBlock("PREMIER")},
|
|
{Label: "deux", Role: "user", Content: bigBlock("SECOND")},
|
|
{Label: "trois", Role: "assistant", Content: bigBlock("TROISIEME")},
|
|
}
|
|
entries, err := archiveRecallBlocks(in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != len(in) {
|
|
t.Fatalf("%d entrées pour %d blocs", len(entries), len(in))
|
|
}
|
|
var last uint64
|
|
for i, e := range entries {
|
|
seq, ok := recallIDToSeq(e.id)
|
|
if !ok || seq <= last {
|
|
t.Fatalf("id %q non croissant (précédent %d)", e.id, last)
|
|
}
|
|
last = seq
|
|
if e.label != in[i].Label {
|
|
t.Fatalf("entrée %d : libellé %q, attendu %q", i, e.label, in[i].Label)
|
|
}
|
|
blk, ok := recallGet(e.id)
|
|
if !ok || blk.Content != in[i].Content || blk.Role != in[i].Role {
|
|
t.Fatalf("bloc %s mal relu : %+v", e.id, blk)
|
|
}
|
|
}
|
|
// Un archivage isolé suit la même séquence.
|
|
id, err := archiveRecallBlock("quatre", "tool", bigBlock("QUATRE"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if seq, _ := recallIDToSeq(id); seq != last+1 {
|
|
t.Fatalf("id isolé %q, attendu r%d", id, last+1)
|
|
}
|
|
}
|