mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Le compactage du contexte detruisait ce dont l'IA avait besoin pour continuer une recherche, ce qui la faisait boucler ou repartir de zero. - Compactage : le resume est desormais fabrique a partir du torse ORIGINAL. On degraissait les resultats d'outils (page web -> marqueur) AVANT de resumer : le resumeur ne voyait plus aucune information trouvee, donc le resume n'en contenait aucune et l'IA relancait les memes recherches sans fin. Les resultats sont seulement raccourcis (1200 car.) pour le resumeur ; le torse degraisse ne sert plus que de repli si le resume echoue. - Compactage : la demande EN COURS est preservee telle quelle. Pendant une boucle d'outils il n'y a aucun message user recent, elle tombait donc dans le torse et se dissolvait dans le resume, tandis que le TOUT PREMIER message de la conversation restait epingle en tete : l'IA repondait a la question du debut et abandonnait la recherche. - Compactage : prompt du resumeur refait (findings, sources deja consultees, etat d'avancement) et plafond du resume porte de 1500 a 2200 caracteres. - Appels repetes : l'avertissement passe EN TETE du resultat et, des la 2e redemande identique, le contenu n'est plus renvoye. Colle a la fin d'un resultat de plusieurs milliers de caracteres, il passait inapercu. - Marqueur d'effacement du compactage : dit explicitement de ne PAS rappeler l'outil (l'ancien texte se lisait comme une invitation a re-telecharger). - Outils web : sortie bornee a 8000 caracteres comme le shell. Un web_read(limit=500) pouvait injecter 25000 caracteres d'un coup et declencher les compactages en cascade. - Bulles d'outils : la borne d'affichage passe de 4000 a 12000 caracteres, donc l'etiquette « ~N tok » cesse d'afficher toujours ~1004 (la valeur du plafond) et donne la vraie taille. Le chemin « appel identique non rejoue » envoyait lui le resultat brut sans troncature.
173 lines
6.7 KiB
Go
173 lines
6.7 KiB
Go
package ajean
|
|
|
|
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 = system + 1er user.
|
|
head, tail := compactBounds(msgs, 1)
|
|
if head != 2 { // system + premier user
|
|
t.Fatalf("head = %d, attendu 2", head)
|
|
}
|
|
// 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)
|
|
}
|
|
}
|