mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 09:47:20 +02:00
Trois apports repris d'AJEAN 0.12.9 → 0.13.5, adaptés au fork. MÉMOIRE LONGUE DE LA CONVERSATION (chat_recall.go) Le compactage résumait, donc perdait. Chaque gros bloc du torse est désormais ARCHIVÉ verbatim sous un identifiant court (r7…) dans bbolt AVANT d'être résumé ; le résumé cite l'id, et le modèle le rappelle avec recall(id) ou le retrouve par mots-clés avec recall_search. Le contexte reste plat, l'archive grossit sur disque. En mode code, un fichier lu ou un diff produit tôt dans la session n'est plus perdu au compactage suivant. Au passage : garde-fou anti-résumé-dégénéré, budget de résumé indexé sur la fenêtre, queue ramenée à 20 % (compacter plus large ne coûte plus de perte), et fin de l'épinglage du 1er message user — le modèle répondait à l'ancienne demande au lieu de continuer la tâche en cours. PROJETS (projects.go) Un projet cloisonne une mémoire, ses discussions et ses trackers. La couture est memoryDir(), qui pointe sur le projet actif : tout le code mémoire en hérite sans le savoir. Migration automatique au premier démarrage (memory/*.md → memory/generale/, discussions et tâches orphelines rattachées). Une tâche planifiée vise un projet et l'exécution le force, pour qu'une veille n'écrive pas dans la mémoire du chantier affiché à l'écran. TRACKERS (tracker.go) 3e type de mémoire : les données datées qui s'accumulent. On ne les lit jamais en entier — consultation par niveaux (vue d'ensemble → année → mois → événements), et la dernière valeur de chaque tracker est donnée d'emblée au modèle, qui répond sans appeler l'outil. Aussi : index MEMORY.md tenu par le CODE et injecté en tête de conversation avec la description du projet (le modèle ne peut plus le désynchroniser) ; prompt système rattaché au PRESET et non plus global — stocké en base, pas dans le .env, qui ne saurait pas porter un texte multiligne. Deux correctifs du lot précédent voyagent ici, faute de pouvoir séparer les fichiers : msgText signale la présence d'une image au compactage, et le garde-fou « pensé sans agir » passe à deux relances (nudgeCount/maxNudges). Le paquet UI est regénéré dans le commit suivant, qui touche les mêmes sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
7.2 KiB
Go
180 lines
7.2 KiB
Go
package loki
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// helpers pour construire des historiques de test lisibles.
|
|
func um(s string) Message { return Message{Role: "user", Content: s} }
|
|
func am(s string) Message { return Message{Role: "assistant", Content: s} }
|
|
func atc(name string) Message {
|
|
return Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Function: ToolCallFunc{Name: name, Arguments: "{}"}}}}
|
|
}
|
|
func tm(s string) Message { return Message{Role: "tool", ToolCallID: "c1", Content: s} }
|
|
|
|
func TestCompactBoundsProtectsHead(t *testing.T) {
|
|
msgs := []Message{
|
|
{Role: "system", Content: "sys"},
|
|
um("premier"), am("r1"),
|
|
um("q2"), am("r2"),
|
|
um("q3"), am("r3"),
|
|
}
|
|
// budget minuscule → queue = juste le dernier tour, tête = les seuls systèmes.
|
|
// Le 1er message user N'EST PLUS épinglé : il l'était pour « ancrer
|
|
// l'objectif », mais après compaction le modèle voyait deux demandes (celle du
|
|
// début, épinglée, et la courante réinjectée par `pending`) et répondait
|
|
// volontiers à l'ancienne. Voir compactBounds et TestCompactKeepsPendingRequest.
|
|
head, tail := compactBounds(msgs, 1)
|
|
if head != 1 { // le message système, et lui seul
|
|
t.Fatalf("head = %d, attendu 1 (systèmes uniquement)", head)
|
|
}
|
|
if msgs[head].Role != "user" {
|
|
t.Fatalf("le 1er message user doit être compactable, il commence le torse ; rôle obtenu %q", msgs[head].Role)
|
|
}
|
|
// Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin).
|
|
if r := msgs[tail].Role; r != "user" && r != "assistant" {
|
|
t.Fatalf("la queue doit démarrer sur un user ou un assistant, obtenu %q", r)
|
|
}
|
|
if tail <= head {
|
|
t.Fatalf("torse vide (tail=%d head=%d) alors qu'il y a du milieu à compacter", tail, head)
|
|
}
|
|
}
|
|
|
|
// La queue ne doit jamais démarrer entre un assistant+tool_calls et ses
|
|
// résultats `tool` : elle recule jusqu'à la frontière sûre précédente (user ou
|
|
// assistant), donc l'assistant part dans la queue AVEC ses résultats.
|
|
func TestCompactBoundsKeepsToolPairs(t *testing.T) {
|
|
msgs := []Message{
|
|
um("q1"), am("r1"),
|
|
um("q2"), atc("bash"), tm("sortie longue"), am("r2"),
|
|
um("q3"), am("r3"),
|
|
}
|
|
// budget moyen qui, sans le recul, couperait au milieu du tour outillé.
|
|
_, tail := compactBounds(msgs, msgTokens(msgs[6])+msgTokens(msgs[7])+msgTokens(msgs[5])+1)
|
|
if r := msgs[tail].Role; r != "user" && r != "assistant" {
|
|
t.Fatalf("la queue démarre sur %q : frontière non sûre (orphelin tool possible)", r)
|
|
}
|
|
// Vérifie qu'aucun message `tool` de la queue n'a perdu son assistant parent.
|
|
for i := tail; i < len(msgs); i++ {
|
|
if msgs[i].Role == "tool" {
|
|
if i == tail || (msgs[i-1].Role != "assistant" && msgs[i-1].Role != "tool") {
|
|
t.Fatalf("message tool orphelin à l'index %d de la queue", i)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Régression : DEUXIÈME compaction à l'intérieur d'un même tour. Une longue
|
|
// boucle d'outils (recherche web : dix pages lues d'affilée) ne contient AUCUN
|
|
// message `user` — reculer jusqu'à un `user` faisait donc avaler toute la
|
|
// séquence par la queue, torse vide, compaction sans effet. La queue doit
|
|
// pouvoir démarrer sur un `assistant`, et les gros résultats d'outils du début
|
|
// doivent se retrouver dans le torse (donc résumés).
|
|
func TestCompactBoundsSplitsLongToolLoop(t *testing.T) {
|
|
page := func(n int) Message {
|
|
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
|
}
|
|
msgs := []Message{
|
|
um("premier"), am("ok"), // head : 1er user protégé
|
|
um("cherche des trucs"), // la dernière demande utilisateur du tour
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
msgs = append(msgs, atc("web_read"), page(i))
|
|
}
|
|
head, tail := compactBounds(msgs, estimateTokens(msgs)/4)
|
|
if tail <= head {
|
|
t.Fatalf("torse vide (tail=%d head=%d) : la boucle d'outils n'est pas compactable", tail, head)
|
|
}
|
|
if r := msgs[tail].Role; r != "user" && r != "assistant" {
|
|
t.Fatalf("la queue démarre sur %q : frontière non sûre", r)
|
|
}
|
|
// Aucun `tool` orphelin en queue.
|
|
for i := tail; i < len(msgs); i++ {
|
|
if msgs[i].Role == "tool" && (i == tail || (msgs[i-1].Role != "assistant" && msgs[i-1].Role != "tool")) {
|
|
t.Fatalf("message tool orphelin à l'index %d de la queue", i)
|
|
}
|
|
}
|
|
// Le torse doit bien contenir des résultats d'outils (c'est ce qui remplit
|
|
// la fenêtre) — sinon la compaction ne libérerait rien.
|
|
tools := 0
|
|
for _, m := range msgs[head:tail] {
|
|
if m.Role == "tool" {
|
|
tools++
|
|
}
|
|
}
|
|
if tools == 0 {
|
|
t.Fatal("aucun résultat d'outil dans le torse : rien à gagner à compacter")
|
|
}
|
|
}
|
|
|
|
// Régression : après compaction pendant une recherche web, la DEMANDE EN COURS
|
|
// doit encore figurer telle quelle dans l'historique. Sans ça, le seul message
|
|
// `user` restant était le tout premier de la conversation (épinglé en tête) et
|
|
// le modèle répondait à celui-là au lieu de continuer la recherche.
|
|
func TestCompactKeepsPendingRequest(t *testing.T) {
|
|
page := func(n int) Message {
|
|
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
|
}
|
|
msgs := []Message{
|
|
um("première question de la conversation"), am("ok"),
|
|
um("cherche les horaires du train pour Lyon"),
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
msgs = append(msgs, atc("web_read"), page(i))
|
|
}
|
|
// summarizeTranscript échoue (pas de llama-server en test) → torse dégraissé,
|
|
// ce qui n'enlève rien à ce qu'on vérifie ici : la demande doit survivre.
|
|
out, _ := compactMessages(t.Context(), msgs, Caps{})
|
|
found := false
|
|
for _, m := range out {
|
|
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("la demande en cours a disparu de l'historique compacté")
|
|
}
|
|
}
|
|
|
|
// Régression : ce qu'on envoie au RÉSUMEUR doit encore contenir les résultats
|
|
// d'outils. On effaçait les résultats (dégraissage) AVANT de résumer : le
|
|
// résumeur ne voyait que des marqueurs, le résumé ne pouvait donc porter aucune
|
|
// information trouvée, et l'IA relançait la même recherche après chaque
|
|
// compactage — sans jamais s'arrêter.
|
|
func TestSummaryInputKeepsToolFindings(t *testing.T) {
|
|
fait := "le train de 14h12 part quai 3"
|
|
torso := []Message{
|
|
atc("web_read"),
|
|
tm(fait + strings.Repeat(" blabla de remplissage", 300)),
|
|
}
|
|
// Même transformation que compactMessages avant l'appel au résumeur.
|
|
forSummary := make([]Message, len(torso))
|
|
for i, m := range torso {
|
|
forSummary[i] = m
|
|
if m.Role == "tool" {
|
|
if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
|
|
forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
|
|
}
|
|
}
|
|
}
|
|
tr := renderTranscript(forSummary)
|
|
if !strings.Contains(tr, fait) {
|
|
t.Fatal("le fait trouvé n'atteint pas le résumeur : le résumé sera vide d'information")
|
|
}
|
|
if strings.Contains(tr, compactPrunedMarker) {
|
|
t.Fatal("le résumeur reçoit des marqueurs d'effacement au lieu du contenu")
|
|
}
|
|
if len([]rune(tr)) > 4000 {
|
|
t.Fatalf("transcription non bornée (%d runes) : le résumeur va déborder", len([]rune(tr)))
|
|
}
|
|
}
|
|
|
|
func TestEstimateTokensGrows(t *testing.T) {
|
|
small := estimateTokens([]Message{um("court")})
|
|
big := estimateTokens([]Message{um("un message nettement plus long que le précédent pour dépasser")})
|
|
if big <= small {
|
|
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
|
|
}
|
|
}
|