From 4a1054c1b798668ac48f24f5537291d28cd78e4b Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 28 Sep 2026 00:16:19 +0200 Subject: [PATCH] =?UTF-8?q?M=C3=A9moire=20:=20apr=C3=A8s=20un=20compactage?= =?UTF-8?q?,=20le=20mod=C3=A8le=20sait=20quelles=20pages=20il=20avait=20lu?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repris d'AJEAN 0.14.0 (chat_mem_pinned.go et ses tests, tels quels). Le compactage résume le torse, résultats de mem_read compris. Une page qui portait TOUTES les règles d'une tâche se retrouvait réduite à trois mots dans le résumé, et le modèle, après compactage, les oubliait. Le rappel ne recopie rien : il LISTE les pages lues (bornées aux 24 plus récentes) et invite à les relire si elles concernent la tâche. Il s'accumule d'un compactage à l'autre — l'ancien rappel est relu comme source, puisque le mem_read d'origine a disparu. Posé seulement en mode agent (sans lui, pas de mem_read pour y donner suite). Comme le contexte projet, le rappel est sorti du bloc système commun vers le premier message utilisateur (isProjectSystem) : il est propre à la conversation et casserait sinon le cache du système partagé. Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_conversation.go | 6 + internal/loki/chat_mem_pinned.go | 163 ++++++++++++++++++++++++++ internal/loki/chat_mem_pinned_test.go | 121 +++++++++++++++++++ internal/loki/llm_client.go | 9 +- 4 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 internal/loki/chat_mem_pinned.go create mode 100644 internal/loki/chat_mem_pinned_test.go diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 180df49..ece3a48 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -358,6 +358,12 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s c.appendDelta(epoch, map[string]any{"compact_noop": true}) return msgs, false } + // Les pages mémoire lues ont été résumées avec le torse : un rappel liste + // leurs noms pour que le modèle les relise au besoin (AJEAN 0.14.0). Agent + // seulement — sans lui, pas d'outil mem_read pour y donner suite. + if caps.Agent { + compacted = remindReadMemPages(compacted, msgs) + } overhead := ctxUsed - estimateTokens(msgs) if overhead < 0 { overhead = 0 diff --git a/internal/loki/chat_mem_pinned.go b/internal/loki/chat_mem_pinned.go new file mode 100644 index 0000000..3e8494c --- /dev/null +++ b/internal/loki/chat_mem_pinned.go @@ -0,0 +1,163 @@ +package loki + +import ( + "encoding/json" + "fmt" + "strings" +) + +// chat_mem_pinned.go — RAPPEL des pages mémoire lues au compactage. +// +// Problème corrigé : quand le contexte est compacté, le torse (dont les résultats +// de mem_read) est résumé. Une page mémoire qui portait, par exemple, TOUTES LES +// RÈGLES à respecter pour une tâche était donc réduite à quelques mots dans le +// résumé — et le modèle, après compactage, « oubliait » les règles et partait en +// vrille. +// +// Choix retenu (le plus léger pour le contexte) : on ne garde RIEN verbatim. On +// injecte simplement, en tête de la conversation compactée, un petit rappel qui +// LISTE les pages que le modèle a lues (mem_read) et l'invite à en relire une si +// elle est pertinente, pour être sûr d'avoir toutes les infos avant de répondre. Le +// rappel ne pèse que les NOMS des pages (borné), le contenu complet reste à un +// mem_read de distance. Le comportement de compactage est par ailleurs inchangé. +// +// Le rappel ACCUMULE les noms à travers les compactages successifs : le rappel du +// compactage précédent est relu comme source, donc une page lue tôt (dont le +// mem_read d'origine a depuis été résumé) reste listée. + +// memReminderPrefix ouvre le message de rappel. Sert aussi à le reconnaître (pour le +// relire comme source au compactage suivant, et pour le retirer avant reconstruction). +const memReminderPrefix = "[MEMORY PAGES REMINDER]" + +// memReminderMaxNames borne le nombre de noms listés : au-delà, une conversation qui +// lit énormément de pages ne ferait pas enfler le rappel. On garde les plus RÉCENTES +// (les plus susceptibles de concerner la tâche en cours). +const memReminderMaxNames = 24 + +// collectReadPageNames rassemble les noms des pages mémoire LUES au fil de la +// conversation, dans l'ordre (première lecture d'abord). Deux sources, toutes deux +// balayées pour survivre aux compactages successifs : +// - les résultats de mem_read encore présents (résultat `tool` relié à sa page via +// l'argument `file` du tool_call correspondant), en ignorant les lectures ratées ; +// - le rappel injecté à un compactage PRÉCÉDENT (une fois le mem_read d'origine +// résumé, c'est la seule trace restante des noms). +func collectReadPageNames(msgs []Message) []string { + seen := map[string]bool{} + var order []string + add := func(name string) { + name = strings.TrimSpace(name) + if name == "" || seen[name] { + return + } + seen[name] = true + order = append(order, name) + } + // tool_call_id → nom de page pour les appels mem_read (le résultat `tool` ne + // porte que l'id ; le nom vit dans l'argument `file` de l'appel de l'assistant). + readCall := map[string]string{} + for _, m := range msgs { + for _, tc := range m.ToolCalls { + if tc.Function.Name != "mem_read" || tc.ID == "" { + continue + } + var a map[string]any + if json.Unmarshal([]byte(tc.Function.Arguments), &a) == nil { + if f, _ := a["file"].(string); f != "" { + readCall[tc.ID] = f + } + } + } + } + for _, m := range msgs { + switch m.Role { + case "system": + for _, n := range parseReminderNames(msgText(m)) { + add(n) + } + case "tool": + name, ok := readCall[m.ToolCallID] + if !ok { + continue + } + if strings.HasPrefix(strings.TrimSpace(msgText(m)), "[erreur]") { + continue // lecture ratée : rien à rappeler + } + add(name) + } + } + return order +} + +// parseReminderNames relit la liste de noms d'un message de rappel produit par +// buildReminderMessage. Renvoie nil si ce n'est pas un rappel. +func parseReminderNames(text string) []string { + if !strings.HasPrefix(text, memReminderPrefix) { + return nil + } + // Format : 1re ligne `[MEMORY PAGES REMINDER] … read earlier: a.md, b.md, c.md` + // (les noms après le dernier `: `, sans ponctuation finale) ; la consigne de + // relecture est sur la ou les lignes SUIVANTES, donc hors de la liste. + line := text + if i := strings.IndexByte(line, '\n'); i >= 0 { + line = line[:i] + } + i := strings.LastIndex(line, ": ") + if i < 0 { + return nil + } + list := line[i+2:] + var out []string + for _, part := range strings.Split(list, ",") { + if n := strings.TrimSpace(part); n != "" { + out = append(out, n) + } + } + return out +} + +// buildReminderMessage construit le message `system` de rappel. Renvoie ok=false si +// aucune page n'a été lue. Borné à memReminderMaxNames (on garde les plus récentes). +func buildReminderMessage(names []string) (Message, bool) { + if len(names) == 0 { + return Message{}, false + } + if len(names) > memReminderMaxNames { + names = names[len(names)-memReminderMaxNames:] // les plus récentes + } + content := fmt.Sprintf("%s Memory pages you read earlier: %s\nTheir full content is no longer inline after context compaction — if any of them is relevant to what you're doing, mem_read it again so you have all the rules and info you need before answering.", + memReminderPrefix, strings.Join(names, ", ")) + return Message{Role: "system", Content: content}, true +} + +// stripReminder retire le(s) message(s) de rappel d'une séquence (avant reconstruction). +func stripReminder(msgs []Message) []Message { + out := make([]Message, 0, len(msgs)) + for _, m := range msgs { + if m.Role == "system" { + if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) { + continue + } + } + out = append(out, m) + } + return out +} + +// remindReadMemPages reconstruit, à partir de l'historique AVANT compactage (source), +// le rappel des pages mémoire lues, et l'injecte en tête de la séquence COMPACTÉE. On +// retire d'abord un éventuel rappel hérité (porté par compacted depuis la tête +// protégée) pour repartir d'un rappel propre et à jour. +// +// Appelé uniquement en mode agent (les outils mem_* existent) et hors mémoire coupée. +// Sans page lue, no-op. +func remindReadMemPages(compacted, source []Message) []Message { + if memMode() == MemOff { + return compacted + } + names := collectReadPageNames(source) + compacted = stripReminder(compacted) + if m, ok := buildReminderMessage(names); ok { + return append([]Message{m}, compacted...) + } + return compacted +} diff --git a/internal/loki/chat_mem_pinned_test.go b/internal/loki/chat_mem_pinned_test.go new file mode 100644 index 0000000..699576f --- /dev/null +++ b/internal/loki/chat_mem_pinned_test.go @@ -0,0 +1,121 @@ +package loki + +import ( + "strings" + "testing" +) + +// Une page mémoire lue (mem_read) doit être retrouvée par son nom (via l'argument +// `file` du tool_call), puis listée dans un rappel relisable. +func TestReminderRoundTrip(t *testing.T) { + msgs := []Message{ + {Role: "user", Content: "applique les règles de la page"}, + {Role: "assistant", ToolCalls: []ToolCall{{ + ID: "call_1", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"regles-tache.md"}`}, + }}}, + {Role: "tool", ToolCallID: "call_1", Content: "RÈGLE 1 : toujours vouvoyer."}, + } + names := collectReadPageNames(msgs) + if len(names) != 1 || names[0] != "regles-tache.md" { + t.Fatalf("nom de page mal collecté : %v", names) + } + m, ok := buildReminderMessage(names) + if !ok { + t.Fatalf("un rappel aurait dû être construit") + } + txt, _ := m.Content.(string) + if !strings.Contains(txt, "regles-tache.md") || !strings.Contains(txt, "mem_read") { + t.Fatalf("le rappel ne cite pas la page ou n'invite pas à relire : %q", txt) + } + // Le rappel doit se relire (noms) pour survivre au compactage suivant. + back := parseReminderNames(txt) + if len(back) != 1 || back[0] != "regles-tache.md" { + t.Fatalf("aller-retour du rappel cassé : %v", back) + } +} + +// Le rappel doit ACCUMULER : un nom listé à un compactage précédent (dont le mem_read +// d'origine a été résumé/effacé) est retrouvé via le rappel système, même sans le +// résultat d'outil correspondant. +func TestReminderAccumulatesAcrossCompactions(t *testing.T) { + old, _ := buildReminderMessage([]string{"regles.md"}) + msgs := []Message{ + old, // rappel hérité (system) + {Role: "user", Content: "[CONTEXT COMPACTED] résumé…"}, + {Role: "assistant", ToolCalls: []ToolCall{{ + ID: "c2", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"procedure.md"}`}, + }}}, + {Role: "tool", ToolCallID: "c2", Content: "procédure détaillée"}, + } + names := collectReadPageNames(msgs) + joined := strings.Join(names, ",") + if !strings.Contains(joined, "regles.md") { + t.Fatalf("la page héritée a été perdue : %v", names) + } + if !strings.Contains(joined, "procedure.md") { + t.Fatalf("la nouvelle page lue n'a pas été captée : %v", names) + } +} + +// Une même page lue plusieurs fois n'est listée qu'une fois. +func TestReminderDedupes(t *testing.T) { + msgs := []Message{ + {Role: "assistant", ToolCalls: []ToolCall{{ID: "a", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}}, + {Role: "tool", ToolCallID: "a", Content: "v1"}, + {Role: "assistant", ToolCalls: []ToolCall{{ID: "b", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}}, + {Role: "tool", ToolCallID: "b", Content: "v2"}, + } + if names := collectReadPageNames(msgs); len(names) != 1 || names[0] != "p.md" { + t.Fatalf("attendu une seule occurrence de p.md, obtenu %v", names) + } +} + +// Une lecture ratée ([erreur] …) ne doit pas être rappelée. +func TestReminderSkipsErrors(t *testing.T) { + msgs := []Message{ + {Role: "assistant", ToolCalls: []ToolCall{{ID: "e", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"absente.md"}`}}}}, + {Role: "tool", ToolCallID: "e", Content: "[erreur] page introuvable"}, + } + if names := collectReadPageNames(msgs); len(names) != 0 { + t.Fatalf("une lecture ratée ne doit pas être rappelée, obtenu %v", names) + } +} + +// La liste de noms est bornée : au-delà du plafond, on garde les plus récentes. +func TestReminderCapsNames(t *testing.T) { + var names []string + for i := 0; i < memReminderMaxNames+10; i++ { + names = append(names, "page-"+string(rune('a'+i%26))+string(rune('0'+i/26))+".md") + } + m, ok := buildReminderMessage(names) + if !ok { + t.Fatalf("rappel attendu") + } + got := parseReminderNames(m.Content.(string)) + if len(got) != memReminderMaxNames { + t.Fatalf("attendu %d noms max, obtenu %d", memReminderMaxNames, len(got)) + } + // La toute dernière (plus récente) doit être conservée. + if got[len(got)-1] != names[len(names)-1] { + t.Fatalf("la page la plus récente doit être gardée : %q vs %q", got[len(got)-1], names[len(names)-1]) + } +} + +// stripReminder retire le rappel sans toucher au reste. +func TestStripReminder(t *testing.T) { + m, _ := buildReminderMessage([]string{"p.md"}) + msgs := []Message{ + m, + {Role: "system", Content: "Project context — autre chose"}, + {Role: "user", Content: "salut"}, + } + out := stripReminder(msgs) + if len(out) != 2 { + t.Fatalf("attendu 2 messages après strip, obtenu %d", len(out)) + } + for _, mm := range out { + if s, _ := mm.Content.(string); strings.HasPrefix(s, memReminderPrefix) { + t.Fatalf("le rappel a survécu au strip") + } + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 9e53f48..48e4128 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -344,8 +344,10 @@ func normalizeSystemMessages(msgs []Message) []Message { } // isProjectSystem : message système propre au projet actif (description, index -// mémoire, trackers), à sortir du bloc système commun (voir -// normalizeSystemMessages). +// mémoire, trackers) ou à la conversation (rappel des pages mémoire lues, posé +// au compactage), à sortir du bloc système commun (voir +// normalizeSystemMessages) : ce bloc doit rester identique partout pour rester +// en cache. func isProjectSystem(m Message) bool { s, ok := m.Content.(string) if !ok || m.Role != "system" { @@ -353,7 +355,8 @@ func isProjectSystem(m Message) bool { } return strings.HasPrefix(s, projectContextPrefix) || strings.HasPrefix(s, memIndexPrefix) || - strings.HasPrefix(s, trackerIndexPrefix) + strings.HasPrefix(s, trackerIndexPrefix) || + strings.HasPrefix(s, memReminderPrefix) } // prependToFirstUser place ctx en tête du premier message user de msgs (modifié