mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +02:00
Contexte : PROJ_SNAPSHOT fige le bloc projet par discussion, ses changements arrivent en mise à jour, en opt-in
Le contexte du projet (description, index mémoire, trackers, AGENTS.md) part en tête du premier message utilisateur, reconstruit à chaque tour : une page créée ou une valeur de tracker notée faisait recalculer toute la conversation derrière lui, 10 à 45k tokens. Nouvelle clé PROJ_SNAPSHOT (off par défaut) : avec on, chaque discussion garde une copie datée du bloc, renvoyée à l'octet près, et les changements arrivent en tête du message suivant dans un bloc <context_update from="loki">, rangé dans l'historique. Sans la clé, la requête est identique à l'octet près (testé contre l'ancien assemblage). - en-têtes figés « as of <date> » pour l'index mémoire et les trackers, sans « answer straight from this » ; ligne fixe du système, seulement avec la clé - mises à jour par type : +/~/- par page (clé « ](fichier) »), une ligne complète par tracker (clé = slug), texte COMPLET pour la description, AGENTS.md, ou un index dont la prose a changé — jamais de diff de prose - état annoncé structuré et persisté (ProjSnap, omitempty), instantané pris sur marqueur explicite : la première page d'un projet vide arrive en mise à jour, le premier message ne bouge pas - rafraîchi (blocs retirés de tout l'historique) quand le prompt change de toute façon : compaction début/fin/manuelle, compaction ou réduction en cours de tour (bloc vivant aussitôt, rangé comme instantané : pas de recalcul de plus), système ou outils modifiés, redémarrage de Loki, réglage moteur ou modèle, projet, nom, mode mémoire, mode Code, dépôt, textes des en-têtes ; et au-delà d'un seuil de mises à jour accumulées - mise à jour posée sous c.mu avec l'epoch, après la compaction de début de tour ; texte ou multimodal ; retirée du titre, de l'export JSON, de l'entrée du résumeur et de la tâche du vérificateur - loadFrom, Reset et le chargement remettent l'instantané à zéro ; clé retirée = nettoyage ; agent off = rien du projet ; tâches et presets externes inchangés ; un sous-agent ne voit pas le bloc du tour - ordre des trackers déjà déterministe (lot 1, test existant) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c9aa04fd9
commit
4932999d5c
13 files changed
+1444
-49
No files matched your search
@@ -368,6 +368,25 @@ Ajoutées par ce fork :
|
||||
vers une API externe ni vers un autre modèle ; un prompt trop long est d'abord
|
||||
rejoué sans raisonnement, et un gabarit qui le refuse le suspend pour ce
|
||||
modèle jusqu'au redémarrage.
|
||||
- **Bloc projet figé** (clé `PROJ_SNAPSHOT`, **off** par défaut,
|
||||
`loki config set PROJ_SNAPSHOT on`) : le contexte du projet (description,
|
||||
index mémoire, trackers, `AGENTS.md`) part en tête du premier message ; sans
|
||||
la clé il est reconstruit à chaque tour, et une page créée ou une valeur de
|
||||
tracker notée fait recalculer toute la conversation derrière lui. Avec la clé,
|
||||
chaque discussion garde une copie datée du bloc, envoyée à l'identique, et les
|
||||
changements arrivent en tête du message suivant dans un bloc
|
||||
`<context_update>` : pages ajoutées, modifiées, retirées, nouvelle ligne d'un
|
||||
tracker, et le texte **complet** d'une description ou d'un `AGENTS.md` modifié
|
||||
— rien n'est perdu, seul ce petit bloc est à calculer. Une ligne du prompt
|
||||
système (présente seulement avec la clé) dit au modèle que ces blocs viennent
|
||||
de Loki et que le plus récent l'emporte. Le bloc est repris tout neuf, et les
|
||||
anciens `<context_update>` retirés, quand le début du prompt change de toute
|
||||
façon : compaction, système ou outils modifiés (date, réglages), redémarrage de
|
||||
Loki, changement de modèle, de preset, de projet, de mode mémoire ou de mode
|
||||
Code — et dès que les mises à jour accumulées deviennent trop longues. Les
|
||||
tâches planifiées gardent le bloc à jour à chaque tour ; sans agent, rien du
|
||||
projet n'est envoyé ; un preset externe (API) n'est jamais concerné. Le titre, l'export JSON, le résumé de compaction et la
|
||||
passe de vérification ne voient pas ces blocs.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -468,6 +468,10 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sans les mises à jour du projet (PROJ_SNAPSHOT) : ce sont des données de
|
||||
// Loki, pas la conversation, et le bloc projet est repris tout neuf après la
|
||||
// compaction. Sans la clé, rien à retirer.
|
||||
forSummary = stripContextUpdates(forSummary)
|
||||
summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
var mid []Message
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
|
||||
@@ -67,6 +67,10 @@ type Conversation struct {
|
||||
// persisté : un rechargement repart du seul CtxUsed, comme avant.
|
||||
ctxUsedLen int
|
||||
genPeak int
|
||||
// ProjSnap : bloc projet figé de la discussion et dernier état annoncé au
|
||||
// modèle (PROJ_SNAPSHOT, chat_projsnap.go). nil = pas d'instantané : le
|
||||
// prochain tour en prend un. Toujours nil sans la clé (JSON inchangé).
|
||||
ProjSnap *projSnapshot `json:"proj_snap,omitempty"`
|
||||
|
||||
Generating bool `json:"-"`
|
||||
cancel context.CancelFunc // annule la génération en cours (/stop)
|
||||
@@ -182,6 +186,8 @@ func LoadConversation() {
|
||||
}
|
||||
conv.mu.Lock()
|
||||
defer conv.mu.Unlock()
|
||||
// Unmarshal ne remet pas à zéro un champ absent du JSON.
|
||||
conv.ProjSnap = nil
|
||||
_ = json.Unmarshal(b, conv)
|
||||
// Guérit les conversations d'avant le passage des captures en éphémère : un
|
||||
// base64 d'image persisté était rejoué à chaque tour et dépassait le contexte.
|
||||
@@ -212,6 +218,10 @@ func (c *Conversation) loadFrom(id string, b []byte) {
|
||||
}
|
||||
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
|
||||
c.ctxUsedLen, c.genPeak = 0, 0
|
||||
// Bloc figé de l'ancienne discussion : json.Unmarshal ne remet pas à zéro un
|
||||
// champ absent, une discussion neuve (ou enregistrée sans lui) l'aurait hérité
|
||||
// — avec la description et l'index d'un autre projet.
|
||||
c.ProjSnap = nil
|
||||
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
|
||||
if len(b) > 0 {
|
||||
_ = json.Unmarshal(b, c)
|
||||
@@ -397,6 +407,12 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
if caps.Agent {
|
||||
compacted = remindReadMemPages(compacted, msgs)
|
||||
}
|
||||
// PROJ_SNAPSHOT : le début du prompt est réécrit, le bloc projet sera donc
|
||||
// repris tout neuf au prochain tour (ProjSnap = nil). Les mises à jour déjà
|
||||
// livrées le suivraient dans la queue gardée telle quelle, une vieille valeur
|
||||
// après la fraîche : retirées ici, elles n'apprennent rien que le bloc neuf
|
||||
// ne dise. Sans la clé, rien à retirer.
|
||||
compacted = stripContextUpdates(compacted)
|
||||
overhead := ctxUsed - estimateTokens(msgs)
|
||||
if overhead < 0 {
|
||||
overhead = 0
|
||||
@@ -405,6 +421,7 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.Messages = compacted
|
||||
c.ProjSnap = nil
|
||||
c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour
|
||||
c.ctxUsedLen = len(compacted)
|
||||
}
|
||||
@@ -797,15 +814,14 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// agent, on veut parler au modèle nu, comme à un llama-server direct. Le
|
||||
// prompt du preset et l'index mémoire décrivent des outils que ce mode n'a
|
||||
// pas : le modèle se mettait à écrire des <tool_call> en clair.
|
||||
final := msgs
|
||||
if caps.Agent {
|
||||
final = append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
//
|
||||
// Assemblage dans turnView (chat_projsnap.go) : avec PROJ_SNAPSHOT, le bloc
|
||||
// projet est celui, figé, de la discussion, et ses changements partent en
|
||||
// tête de ce message — après la compaction de début de tour, sous c.mu.
|
||||
sent, tools, snapTurn := c.turnView(caps, epoch, msgs, caps.Agent)
|
||||
runCtx := ctx
|
||||
if snapTurn != nil {
|
||||
runCtx = withProjSnapTurn(ctx, snapTurn)
|
||||
}
|
||||
|
||||
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
|
||||
@@ -825,8 +841,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
|
||||
// pas de vérification du mode Code avant sa réponse.
|
||||
asked := false
|
||||
sent, tools := prepareTurn(final, caps)
|
||||
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
extra, _ := runChatTools(runCtx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||
@@ -927,6 +942,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if newBase != nil {
|
||||
c.Messages = newBase
|
||||
}
|
||||
// Bloc projet rafraîchi par une compaction en cours de tour
|
||||
// (PROJ_SNAPSHOT) : c'est lui que le tour suivant renvoie.
|
||||
if s := snapTurn.takeRefreshed(); s != nil {
|
||||
c.ProjSnap = s
|
||||
}
|
||||
c.Messages = append(c.Messages, extra...)
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, withEcho(Message{Role: "assistant", Content: s}, echo))
|
||||
@@ -1080,6 +1100,7 @@ func (c *Conversation) Reset() {
|
||||
c.Seq = 0
|
||||
c.CtxUsed = 0
|
||||
c.ctxUsedLen, c.genPeak = 0, 0
|
||||
c.ProjSnap = nil
|
||||
c.epoch++
|
||||
c.Generating = false
|
||||
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
|
||||
|
||||
@@ -103,8 +103,10 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
|
||||
Version: Version,
|
||||
ExportedAt: time.Now().Format(time.RFC3339),
|
||||
CtxUsed: ctxUsed,
|
||||
Messages: stripInlineImages(msgs),
|
||||
Log: filterLog(log, o),
|
||||
// Sans les mises à jour du projet (PROJ_SNAPSHOT) : l'export rend la
|
||||
// conversation, pas les données que Loki y a glissées.
|
||||
Messages: stripInlineImages(stripContextUpdates(msgs)),
|
||||
Log: filterLog(log, o),
|
||||
}
|
||||
return json.MarshalIndent(p, "", " ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
package loki
|
||||
|
||||
// chat_projsnap.go — PROJ_SNAPSHOT : le bloc projet FIGÉ par discussion, et ses
|
||||
// changements livrés en <context_update> dans le message suivant.
|
||||
//
|
||||
// Pourquoi. Le contexte du projet (description, index mémoire, trackers,
|
||||
// consignes du dépôt) part en tête du PREMIER message utilisateur
|
||||
// (normalizeSystemMessages). Reconstruit à chaque tour, il change dès qu'une page
|
||||
// est créée ou qu'un tracker reçoit un point — et tout ce qui le suit dans le
|
||||
// prompt est recalculé : 10 à 45k tokens, des dizaines de secondes sur un 27B,
|
||||
// des minutes sur un MoE déchargé. Un modèle qui note une valeur à chaque tour
|
||||
// paie ce prix à chaque tour.
|
||||
//
|
||||
// Avec la clé (off par défaut) :
|
||||
// - la discussion garde une copie figée du bloc (ProjSnap), envoyée à l'octet
|
||||
// près d'un tour à l'autre. Ses en-têtes disent « à jour au <date> » et que
|
||||
// les <context_update> de Loki les complètent ;
|
||||
// - un changement arrive en tête du NOUVEAU message utilisateur, rangé tel quel
|
||||
// dans l'historique (le tour suivant renvoie les mêmes octets). Le moteur
|
||||
// pose un point de reprise au début de chaque message utilisateur : seul ce
|
||||
// petit bloc est à calculer ;
|
||||
// - rien n'est perdu : lignes ajoutées, modifiées, retirées pour les données
|
||||
// faites de lignes (pages, trackers), texte COMPLET pour la prose
|
||||
// (description, AGENTS.md). Une ligne fixe du système dit que ces blocs
|
||||
// viennent de Loki et que le plus récent l'emporte.
|
||||
//
|
||||
// Le bloc est rafraîchi — copie neuve, blocs <context_update> retirés de tout
|
||||
// l'historique — seulement quand le début du prompt change de toute façon :
|
||||
// compaction (début, fin de tour, manuelle, en cours de tour, réduction forcée),
|
||||
// système ou outils modifiés (date du jour, réglages, mode), cache froid
|
||||
// (redémarrage de Loki, changement de modèle, de preset ou de réglage moteur),
|
||||
// projet, nom, mode mémoire, mode Code ou dépôt changés, textes des en-têtes
|
||||
// changés. Et quand les mises à jour accumulées dépassent un seuil : au-delà, le
|
||||
// modèle aurait trop à recoller.
|
||||
//
|
||||
// Hors discussion : une tâche planifiée garde le bloc vivant (elle repart de zéro
|
||||
// à chaque fois), et sans agent le modèle brut ne reçoit rien du projet — ni bloc,
|
||||
// ni mise à jour.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// projSnapEnabled : la clé PROJ_SNAPSHOT demande-t-elle le bloc figé ? Jamais
|
||||
// pour un preset externe (API OpenAI-compatible) : ses requêtes restent celles
|
||||
// d'avant, quel que soit le réglage.
|
||||
func projSnapEnabled(cfg map[string]string) bool {
|
||||
if isExternalConfig(cfg) {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["PROJ_SNAPSHOT"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Textes propres au bloc figé. Leur empreinte entre dans la clé de validité :
|
||||
// une mise à jour de Loki qui les reformule rafraîchit les instantanés persistés.
|
||||
const (
|
||||
projSnapFormat = "1"
|
||||
memIndexFrozenTail = " Later <context_update> blocks from Loki update it."
|
||||
trackerFrozenHead = " — your trackers, each with its latest point at that time; later <context_update> blocks from Loki replace these lines. " +
|
||||
"To say which trackers exist or give a latest value, use this list with those updates — do NOT call tracker with no arguments to list them. " +
|
||||
"Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n"
|
||||
ctxUpdOpen = `<context_update from="loki"`
|
||||
ctxUpdClose = "</context_update>"
|
||||
// projSnapSystemLine : la ligne fixe du prompt système, clé posée seulement.
|
||||
projSnapSystemLine = "A <context_update from=\"loki\"> block at the start of a user message is written by Loki, not by the user: it updates <project_context>, and the most recent one wins.\n"
|
||||
)
|
||||
|
||||
// projSnapBoot : identifiant de ce lancement de Loki. Après un redémarrage, le
|
||||
// cache du moteur est froid : garder un vieil instantané n'y gagnerait rien.
|
||||
var projSnapBoot = strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
|
||||
// projSnapNow : l'horodatage « à jour au » des en-têtes et des mises à jour.
|
||||
func projSnapNow() string { return time.Now().Format("2006-01-02 15:04") }
|
||||
|
||||
// projState : ce que le modèle sait du projet, section par section — au moment
|
||||
// de l'instantané, puis après chaque mise à jour annoncée. Structuré, jamais un
|
||||
// simple hash : c'est contre lui qu'on calcule la mise à jour suivante, y
|
||||
// compris après un redémarrage.
|
||||
type projState struct {
|
||||
Code string `json:"code,omitempty"` // consignes du dépôt, message complet
|
||||
Desc string `json:"desc,omitempty"` // description du projet
|
||||
MemOn bool `json:"mem_on,omitempty"` // index mémoire présent
|
||||
MemProse string `json:"mem_prose,omitempty"` // lignes de l'index qui ne sont pas des pages
|
||||
Mem map[string]string `json:"mem,omitempty"` // fichier → ligne de page
|
||||
Trk map[string]string `json:"trk,omitempty"` // slug → « nom — valeur (date) »
|
||||
}
|
||||
|
||||
// projSnapshot : le bloc figé d'une discussion. Persisté avec elle (omitempty :
|
||||
// absent tant que la clé n'a jamais servi, et ignoré d'un Loki plus ancien).
|
||||
type projSnapshot struct {
|
||||
At int64 `json:"at"` // instant de l'instantané (ms)
|
||||
Msgs []Message `json:"msgs,omitempty"` // le bloc tel qu'il part, à l'octet près
|
||||
State projState `json:"state"` // dernier état annoncé au modèle
|
||||
Key string `json:"key"` // validité : lancement, réglages, projet, mode…
|
||||
SysHash string `json:"sys_hash"` // système + outils du dernier tour
|
||||
SnapTok int `json:"snap_tok,omitempty"`
|
||||
DeltaTok int `json:"delta_tok,omitempty"` // mises à jour livrées depuis
|
||||
}
|
||||
|
||||
// projLive : le bloc vivant, rendu au format figé, avec son état. memOrder /
|
||||
// trkOrder : ordre d'affichage, pour des mises à jour stables.
|
||||
type projLive struct {
|
||||
msgs []Message
|
||||
state projState
|
||||
memMsg Message
|
||||
trkMsg Message
|
||||
memOrder []string
|
||||
trkOrder []string
|
||||
}
|
||||
|
||||
// projSnapCapture lit le contexte projet UNE fois et en tire le bloc (même
|
||||
// ordre que l'assemblage d'un tour : consignes du dépôt, description, index
|
||||
// mémoire, trackers) et son état.
|
||||
func projSnapCapture(caps Caps, asOf string) projLive {
|
||||
var lv projLive
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
lv.msgs = append(lv.msgs, m)
|
||||
lv.state.Code, _ = m.Content.(string)
|
||||
}
|
||||
if desc := strings.TrimSpace(projectDesc(activeProjectSlug())); desc != "" {
|
||||
lv.msgs = append(lv.msgs, renderProjectContext(desc))
|
||||
lv.state.Desc = desc
|
||||
}
|
||||
if idx, ok := memIndexText(); ok {
|
||||
lv.memMsg = renderMemIndex(idx, asOf)
|
||||
lv.msgs = append(lv.msgs, lv.memMsg)
|
||||
lv.state.MemOn = true
|
||||
lv.state.MemProse, lv.state.Mem, lv.memOrder = splitMemIndex(idx)
|
||||
}
|
||||
if list, ok := trackerIndexList(); ok {
|
||||
lv.trkMsg = renderTrackerIndex(list, asOf)
|
||||
lv.msgs = append(lv.msgs, lv.trkMsg)
|
||||
lv.state.Trk = map[string]string{}
|
||||
for _, m := range list {
|
||||
lv.state.Trk[m.Slug] = trackerIndexLine(m)
|
||||
lv.trkOrder = append(lv.trkOrder, m.Slug)
|
||||
}
|
||||
}
|
||||
return lv
|
||||
}
|
||||
|
||||
// splitMemIndex sépare les lignes de page (clé = fichier entre « ]( » et « ) »)
|
||||
// du reste de l'index. Une ligne de page en double part avec la prose : ainsi
|
||||
// tout changement la concernant fait renvoyer l'index entier, sans perte.
|
||||
func splitMemIndex(idx string) (prose string, pages map[string]string, order []string) {
|
||||
pages = map[string]string{}
|
||||
var other []string
|
||||
for _, line := range strings.Split(idx, "\n") {
|
||||
l := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(l, "- [") {
|
||||
if i := strings.Index(l, "]("); i >= 0 {
|
||||
if j := strings.Index(l[i+2:], ")"); j > 0 {
|
||||
key := l[i+2 : i+2+j]
|
||||
if _, dup := pages[key]; !dup {
|
||||
pages[key] = l
|
||||
order = append(order, key)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other = append(other, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(other, "\n")), pages, order
|
||||
}
|
||||
|
||||
func newProjSnapshot(lv projLive, key, sysHash string) *projSnapshot {
|
||||
tok := 0
|
||||
for _, m := range lv.msgs {
|
||||
tok += len(msgText(m)) / 4
|
||||
}
|
||||
return &projSnapshot{At: time.Now().UnixMilli(), Msgs: lv.msgs, State: lv.state, Key: key, SysHash: sysHash, SnapTok: tok}
|
||||
}
|
||||
|
||||
// projSnapDeltaLimit : au-delà de ce volume de mises à jour depuis l'instantané
|
||||
// (en tokens estimés), on rafraîchit plutôt que d'en empiler d'autres : environ
|
||||
// un quart du bloc, entre 400 et 1500 tokens.
|
||||
func projSnapDeltaLimit(snapTok int) int {
|
||||
return min(1500, max(400, snapTok/4))
|
||||
}
|
||||
|
||||
// projSnapKey : tout ce qui, en changeant, rend l'instantané faux ou inutile.
|
||||
// Les réglages pris en compte sont ceux qui touchent au moteur (modèle, preset,
|
||||
// arguments : un changement le relance, le cache est froid) ; les boutons de
|
||||
// conversation qui ne le relancent pas en sont exclus.
|
||||
func projSnapKey(caps Caps, cfg map[string]string) string {
|
||||
h := fnv.New64a()
|
||||
w := func(s string) {
|
||||
h.Write([]byte(s))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
w(projSnapFormat)
|
||||
w(projSnapBoot)
|
||||
keys := make([]string, 0, len(cfg))
|
||||
for k := range cfg {
|
||||
if !projSnapIgnoredKeys[k] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
w(k)
|
||||
w(cfg[k])
|
||||
}
|
||||
slug := activeProjectSlug()
|
||||
w(slug)
|
||||
w(projectName(slug))
|
||||
w(string(memMode()))
|
||||
w(strconv.FormatBool(caps.Code))
|
||||
if caps.Code {
|
||||
d, _ := gitRepoDir("")
|
||||
w(d)
|
||||
w(agentCwd())
|
||||
}
|
||||
for _, s := range []string{memIndexFrozenTail, trackerFrozenHead, ctxUpdOpen, ctxUpdClose, projSnapSystemLine} {
|
||||
w(s)
|
||||
}
|
||||
return fmt.Sprintf("%016x", h.Sum64())
|
||||
}
|
||||
|
||||
// projSnapIgnoredKeys : réglages changés en cours de discussion sans relancer le
|
||||
// moteur ni toucher au bloc. Les compter rafraîchirait pour rien.
|
||||
var projSnapIgnoredKeys = map[string]bool{
|
||||
"PROJ_SNAPSHOT": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true,
|
||||
"COMPACT": true, "CRAWL4AI_URL": true, "CRAWL4AI_KEY": true, "WEB_ENGINE": true,
|
||||
}
|
||||
|
||||
// projSnapSysHash : empreinte du bloc système commun et des outils. S'ils
|
||||
// changent, tout le prompt est recalculé de toute façon : rafraîchir est gratuit.
|
||||
func projSnapSysHash(sent []Message, tools []Tool) string {
|
||||
h := fnv.New64a()
|
||||
for _, m := range sent {
|
||||
if m.Role != "system" {
|
||||
break
|
||||
}
|
||||
if isProjectSystem(m) {
|
||||
continue
|
||||
}
|
||||
s, _ := m.Content.(string)
|
||||
h.Write([]byte(s))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
b, _ := json.Marshal(tools)
|
||||
h.Write(b)
|
||||
return fmt.Sprintf("%016x", h.Sum64())
|
||||
}
|
||||
|
||||
// projDelta : la mise à jour à annoncer entre l'état déjà connu du modèle et le
|
||||
// bloc vivant. "" = rien de changé. Les sections faites de lignes changent ligne
|
||||
// à ligne (une ligne de tracker remplace la précédente) ; la prose, ou une
|
||||
// section qui apparaît, repart en entier.
|
||||
func projDelta(old projState, lv projLive, asOf string) string {
|
||||
cur := lv.state
|
||||
var b strings.Builder
|
||||
full := func(m Message) {
|
||||
b.WriteString("Current version, replaces any earlier one — " + msgText(m) + "\n")
|
||||
}
|
||||
if old.Code != cur.Code {
|
||||
if cur.Code == "" {
|
||||
b.WriteString("Repository instructions: removed.\n")
|
||||
} else {
|
||||
full(Message{Content: cur.Code})
|
||||
}
|
||||
}
|
||||
if old.Desc != cur.Desc {
|
||||
if cur.Desc == "" {
|
||||
b.WriteString("Project description: removed.\n")
|
||||
} else {
|
||||
full(renderProjectContext(cur.Desc))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !cur.MemOn && old.MemOn:
|
||||
b.WriteString("Memory index: no pages any more.\n")
|
||||
case cur.MemOn && (!old.MemOn || old.MemProse != cur.MemProse):
|
||||
full(lv.memMsg)
|
||||
case cur.MemOn:
|
||||
var lines []string
|
||||
for _, k := range lv.memOrder {
|
||||
l := strings.TrimPrefix(cur.Mem[k], "- ")
|
||||
switch prev, ok := old.Mem[k]; {
|
||||
case !ok:
|
||||
lines = append(lines, "+ "+l)
|
||||
case prev != cur.Mem[k]:
|
||||
lines = append(lines, "~ "+l)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedMissing(old.Mem, cur.Mem) {
|
||||
lines = append(lines, "- "+k+" (deleted)")
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
b.WriteString("Memory index changes:\n" + strings.Join(lines, "\n") + "\n")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(cur.Trk) == 0 && len(old.Trk) > 0:
|
||||
b.WriteString("Trackers: none left.\n")
|
||||
case len(cur.Trk) > 0 && len(old.Trk) == 0:
|
||||
full(lv.trkMsg)
|
||||
case len(cur.Trk) > 0:
|
||||
var lines []string
|
||||
for _, k := range lv.trkOrder {
|
||||
switch prev, ok := old.Trk[k]; {
|
||||
case !ok:
|
||||
lines = append(lines, "+ "+cur.Trk[k])
|
||||
case prev != cur.Trk[k]:
|
||||
lines = append(lines, "~ "+cur.Trk[k])
|
||||
}
|
||||
}
|
||||
for _, k := range sortedMissing(old.Trk, cur.Trk) {
|
||||
lines = append(lines, "- "+old.Trk[k]+" (deleted)")
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
b.WriteString("Tracker changes (latest point):\n" + strings.Join(lines, "\n") + "\n")
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
// Un titre de page qui contiendrait la balise fermante couperait le bloc au
|
||||
// mauvais endroit au moment de le retirer.
|
||||
body := strings.ReplaceAll(b.String(), ctxUpdClose, "</context-update>")
|
||||
return ctxUpdOpen + ` at="` + asOf + `">` + "\n" + body + ctxUpdClose + "\n\n"
|
||||
}
|
||||
|
||||
// sortedMissing : clés de old absentes de cur, triées.
|
||||
func sortedMissing(old, cur map[string]string) []string {
|
||||
var out []string
|
||||
for k := range old {
|
||||
if _, ok := cur[k]; !ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// --- blocs <context_update> dans l'historique -----------------------------
|
||||
|
||||
// stripCtxUpdText retire le(s) bloc(s) posé(s) en TÊTE du texte. Seulement en
|
||||
// tête : un message qui en parle plus loin reste intact.
|
||||
func stripCtxUpdText(s string) (string, bool) {
|
||||
stripped := false
|
||||
for strings.HasPrefix(s, ctxUpdOpen) {
|
||||
i := strings.Index(s, ctxUpdClose)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
s = strings.TrimPrefix(s[i+len(ctxUpdClose):], "\n\n")
|
||||
stripped = true
|
||||
}
|
||||
return s, stripped
|
||||
}
|
||||
|
||||
// withoutCtxUpdate : le message sans son bloc de tête, ok=false s'il n'en avait
|
||||
// pas (message rendu tel quel). Contenu texte ou multimodal ([]any, []map).
|
||||
func withoutCtxUpdate(m Message) (Message, bool) {
|
||||
if m.Role != "user" {
|
||||
return m, false
|
||||
}
|
||||
switch c := m.Content.(type) {
|
||||
case string:
|
||||
if s, ok := stripCtxUpdText(c); ok {
|
||||
m.Content = s
|
||||
return m, true
|
||||
}
|
||||
case []any:
|
||||
if len(c) > 0 {
|
||||
if p, ok := c[0].(map[string]any); ok && p["type"] == "text" {
|
||||
if t, _ := p["text"].(string); t != "" {
|
||||
if s, ok := stripCtxUpdText(t); ok {
|
||||
out := append([]any(nil), c[1:]...)
|
||||
if s != "" {
|
||||
q := map[string]any{}
|
||||
for k, v := range p {
|
||||
q[k] = v
|
||||
}
|
||||
q["text"] = s
|
||||
out = append([]any{q}, out...)
|
||||
}
|
||||
m.Content = out
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case []map[string]any:
|
||||
if len(c) > 0 && c[0]["type"] == "text" {
|
||||
if t, _ := c[0]["text"].(string); t != "" {
|
||||
if s, ok := stripCtxUpdText(t); ok {
|
||||
out := append([]map[string]any(nil), c[1:]...)
|
||||
if s != "" {
|
||||
q := map[string]any{}
|
||||
for k, v := range c[0] {
|
||||
q[k] = v
|
||||
}
|
||||
q["text"] = s
|
||||
out = append([]map[string]any{q}, out...)
|
||||
}
|
||||
m.Content = out
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, false
|
||||
}
|
||||
|
||||
// stripContextUpdates rend l'historique sans aucun bloc <context_update>. Sans
|
||||
// bloc, c'est la MÊME tranche (rien recopié) : le cas de toutes les discussions
|
||||
// sans la clé.
|
||||
func stripContextUpdates(msgs []Message) []Message {
|
||||
var out []Message
|
||||
for i, m := range msgs {
|
||||
if s, ok := withoutCtxUpdate(m); ok {
|
||||
if out == nil {
|
||||
out = append([]Message(nil), msgs...)
|
||||
}
|
||||
out[i] = s
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
return msgs
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasContextUpdates(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
if _, ok := withoutCtxUpdate(m); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// prependCtxUpdate pose le bloc en tête d'un message utilisateur, comme
|
||||
// prependToFirstUser : texte simple, ou une partie texte de plus en multimodal.
|
||||
func prependCtxUpdate(m Message, block string) (Message, bool) {
|
||||
if m.Role != "user" {
|
||||
return m, false
|
||||
}
|
||||
switch c := m.Content.(type) {
|
||||
case string:
|
||||
m.Content = block + c
|
||||
case []any:
|
||||
m.Content = append([]any{map[string]any{"type": "text", "text": block}}, c...)
|
||||
case []map[string]any:
|
||||
m.Content = append([]map[string]any{{"type": "text", "text": block}}, c...)
|
||||
default:
|
||||
return m, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
// replaceProjectHead remplace le bloc projet de tête (hors rappel des pages
|
||||
// lues, qui appartient à l'historique) par block, à la même place.
|
||||
func replaceProjectHead(msgs []Message, block []Message) []Message {
|
||||
end := 0
|
||||
for end < len(msgs) && msgs[end].Role == "system" {
|
||||
end++
|
||||
}
|
||||
out := make([]Message, 0, len(msgs)+len(block))
|
||||
inserted := false
|
||||
for _, m := range msgs[:end] {
|
||||
reminder := false
|
||||
if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) {
|
||||
reminder = true
|
||||
}
|
||||
if isProjectSystem(m) && !reminder {
|
||||
if !inserted {
|
||||
out = append(out, block...)
|
||||
inserted = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if reminder && !inserted {
|
||||
out = append(out, block...)
|
||||
inserted = true
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if !inserted {
|
||||
out = append(out, block...)
|
||||
}
|
||||
return append(out, msgs[end:]...)
|
||||
}
|
||||
|
||||
// --- assemblage d'un tour de la discussion --------------------------------
|
||||
|
||||
// turnView assemble la séquence envoyée pour un tour de la discussion (generate,
|
||||
// runBuilderTurn) : prompt du preset, consignes du dépôt, contexte projet, puis
|
||||
// l'historique. inject=false : modèle brut (agent off), rien du projet.
|
||||
//
|
||||
// Sans PROJ_SNAPSHOT : exactement l'assemblage d'avant, contexte vivant. Seul
|
||||
// ajout, sans effet tant que la clé n'a jamais servi : une discussion qui porte
|
||||
// encore un instantané ou des blocs d'une époque où la clé était posée en est
|
||||
// nettoyée — un vieux <context_update> placé après le bloc vivant se lirait
|
||||
// comme plus récent que lui.
|
||||
//
|
||||
// Avec la clé : instantané pris, gardé ou rafraîchi ; mise à jour éventuelle
|
||||
// posée en tête du dernier message utilisateur, dans c.Messages ET dans msgs
|
||||
// (sous c.mu, si l'epoch n'a pas changé). pt sert au rafraîchissement en cours
|
||||
// de tour (runChatTools) ; nil sans la clé.
|
||||
func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject bool) (sent []Message, tools []Tool, pt *projSnapTurn) {
|
||||
cfg := ReadConfig()
|
||||
on := projSnapEnabled(cfg)
|
||||
if !inject {
|
||||
// Sans agent : ni bloc, ni mise à jour. Des blocs restés d'un tour avec
|
||||
// agent sont retirés de la vue envoyée (l'historique les garde : l'agent
|
||||
// revenu, le système change et le bloc est rafraîchi).
|
||||
sent, tools = prepareTurn(stripContextUpdates(msgs), caps)
|
||||
return sent, tools, nil
|
||||
}
|
||||
if !on {
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch && (c.ProjSnap != nil || hasContextUpdates(c.Messages)) {
|
||||
c.ProjSnap = nil
|
||||
c.Messages = stripContextUpdates(c.Messages)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
msgs = stripContextUpdates(msgs)
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
sent, tools = prepareTurn(final, caps)
|
||||
return sent, tools, nil
|
||||
}
|
||||
|
||||
// Lectures (disque, base, git) hors du verrou de la conversation.
|
||||
asOf := projSnapNow()
|
||||
live := projSnapCapture(caps, asOf)
|
||||
key := projSnapKey(caps, cfg)
|
||||
// Même calcul que prepareTurn, en deux temps : l'empreinte du système et des
|
||||
// outils décide du rafraîchissement avant l'assemblage. Les outils ne sont
|
||||
// calculés qu'une fois (connexions MCP).
|
||||
tools = EnabledTools(caps)
|
||||
var spMsgs []Message
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
spMsgs = []Message{{Role: "system", Content: sp}}
|
||||
}
|
||||
sysHash := projSnapSysHash(InjectSkills(spMsgs, caps, tools), tools)
|
||||
|
||||
block := live.msgs
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
refresh, delta := c.projSnapApplyLocked(live, key, sysHash, asOf)
|
||||
if refresh {
|
||||
msgs = stripContextUpdates(msgs)
|
||||
}
|
||||
if delta != "" {
|
||||
if n := len(msgs); n > 0 {
|
||||
if m, ok := prependCtxUpdate(msgs[n-1], delta); ok {
|
||||
msgs = append(msgs[:n-1:n-1], m)
|
||||
}
|
||||
}
|
||||
}
|
||||
block = c.ProjSnap.Msgs
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
final := append(append(append([]Message(nil), spMsgs...), block...), msgs...)
|
||||
sent = InjectSkills(final, caps, tools)
|
||||
return sent, tools, &projSnapTurn{caps: caps, key: key, sysHash: sysHash}
|
||||
}
|
||||
|
||||
// projSnapApplyLocked décide pour ce tour, c.mu tenu : rafraîchir (instantané
|
||||
// neuf, blocs retirés de l'historique) ou annoncer une mise à jour, posée en tête
|
||||
// du dernier message utilisateur de c.Messages. Renvoie ce qui a été fait, pour
|
||||
// que l'appelant l'applique à sa propre copie.
|
||||
func (c *Conversation) projSnapApplyLocked(live projLive, key, sysHash, asOf string) (refresh bool, delta string) {
|
||||
snap := c.ProjSnap
|
||||
refresh = snap == nil || snap.Key != key || snap.SysHash != sysHash
|
||||
tok := 0
|
||||
if !refresh {
|
||||
delta = projDelta(snap.State, live, asOf)
|
||||
tok = len(delta) / 4
|
||||
if delta != "" && snap.DeltaTok+tok > projSnapDeltaLimit(snap.SnapTok) {
|
||||
refresh = true
|
||||
}
|
||||
}
|
||||
if refresh {
|
||||
c.Messages = stripContextUpdates(c.Messages)
|
||||
c.ProjSnap = newProjSnapshot(live, key, sysHash)
|
||||
return true, ""
|
||||
}
|
||||
if delta == "" {
|
||||
return false, ""
|
||||
}
|
||||
n := len(c.Messages)
|
||||
if n == 0 {
|
||||
return false, ""
|
||||
}
|
||||
m, ok := prependCtxUpdate(c.Messages[n-1], delta)
|
||||
if !ok {
|
||||
// Pas de message utilisateur où la poser (cas théorique) : l'état
|
||||
// annoncé ne bouge pas, elle partira au tour suivant.
|
||||
return false, ""
|
||||
}
|
||||
c.Messages[n-1] = m
|
||||
snap.State = live.state
|
||||
snap.DeltaTok += tok
|
||||
return false, delta
|
||||
}
|
||||
|
||||
// --- rafraîchissement en cours de tour -------------------------------------
|
||||
|
||||
// projSnapTurn accompagne un tour de discussion avec la clé posée. runChatTools
|
||||
// le récupère dans le contexte et s'en sert quand une compaction ou une
|
||||
// réduction réécrit l'historique en plein tour : le début du prompt change de
|
||||
// toute façon, le bloc est donc remplacé par le vivant (et les mises à jour
|
||||
// retirées), sans perdre celles qu'aurait avalées le résumé. L'appelant range
|
||||
// ensuite refreshed comme nouvel instantané : le tour suivant renvoie les mêmes
|
||||
// octets, pas de recalcul de plus.
|
||||
type projSnapTurn struct {
|
||||
caps Caps
|
||||
key string
|
||||
sysHash string
|
||||
|
||||
mu sync.Mutex
|
||||
refreshed *projSnapshot
|
||||
}
|
||||
|
||||
type projSnapCtxKey struct{}
|
||||
|
||||
func withProjSnapTurn(ctx context.Context, pt *projSnapTurn) context.Context {
|
||||
return context.WithValue(ctx, projSnapCtxKey{}, pt)
|
||||
}
|
||||
|
||||
func projSnapTurnFrom(ctx context.Context) *projSnapTurn {
|
||||
pt, _ := ctx.Value(projSnapCtxKey{}).(*projSnapTurn)
|
||||
return pt
|
||||
}
|
||||
|
||||
// refresh : historique en cours de tour, bloc vivant à la place du figé, sans
|
||||
// aucun <context_update>.
|
||||
func (pt *projSnapTurn) refresh(msgs []Message) []Message {
|
||||
live := projSnapCapture(pt.caps, projSnapNow())
|
||||
out := replaceProjectHead(stripContextUpdates(msgs), live.msgs)
|
||||
pt.mu.Lock()
|
||||
pt.refreshed = newProjSnapshot(live, pt.key, pt.sysHash)
|
||||
pt.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// takeRefreshed : l'instantané posé en cours de tour, nil s'il n'y en a pas eu.
|
||||
func (pt *projSnapTurn) takeRefreshed() *projSnapshot {
|
||||
if pt == nil {
|
||||
return nil
|
||||
}
|
||||
pt.mu.Lock()
|
||||
defer pt.mu.Unlock()
|
||||
s := pt.refreshed
|
||||
pt.refreshed = nil
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// projSnapSetup : base et projet neufs, moteur de comédie qui répond « ok. ».
|
||||
// on = PROJ_SNAPSHOT posée.
|
||||
func projSnapSetup(t *testing.T, on bool) *moteurEcho {
|
||||
t.Helper()
|
||||
withWorkspace(t)
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
})
|
||||
ensureDefaultProject()
|
||||
freshTplProbe(t)
|
||||
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if on {
|
||||
if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &moteurEcho{reply: func(int, string) (int, string) {
|
||||
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
|
||||
}}
|
||||
m.start(t)
|
||||
return m
|
||||
}
|
||||
|
||||
// playTurn joue un tour utilisateur complet sur c et rend le corps de la PREMIÈRE
|
||||
// requête de ce tour.
|
||||
func playTurn(t *testing.T, m *moteurEcho, c *Conversation, caps Caps, text string) string {
|
||||
t.Helper()
|
||||
before := len(m.all())
|
||||
c.mu.Lock()
|
||||
c.Messages = append(c.Messages, um(text))
|
||||
c.mu.Unlock()
|
||||
c.generate(context.Background(), caps, 0.7, c.epoch)
|
||||
all := m.all()
|
||||
if len(all) <= before {
|
||||
t.Fatalf("aucune requête pour %q", text)
|
||||
}
|
||||
return all[before]
|
||||
}
|
||||
|
||||
// userContents : le texte des messages user d'une requête, dans l'ordre.
|
||||
func userContents(t *testing.T, body string) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for _, m := range requestMessages(t, body) {
|
||||
if m["role"] == "user" {
|
||||
s, _ := m["content"].(string)
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func systemContent(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
for _, m := range requestMessages(t, body) {
|
||||
if m["role"] == "system" {
|
||||
s, _ := m["content"].(string)
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// oldAssembly : l'assemblage de generate d'avant PROJ_SNAPSHOT, recopié tel quel.
|
||||
func oldAssembly(caps Caps, msgs []Message) []Message {
|
||||
final := msgs
|
||||
if caps.Agent {
|
||||
final = append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
}
|
||||
sent, _ := prepareTurn(final, caps)
|
||||
return normalizeSystemMessages(sent)
|
||||
}
|
||||
|
||||
func jsonValue(t *testing.T, v any) any {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Clé absente : la requête est celle d'avant, à l'octet près — bloc vivant qui
|
||||
// suit la page créée, aucune ligne système de plus, rien de persisté en plus.
|
||||
func TestProjSnapDefautRienNeChange(t *testing.T) {
|
||||
m := projSnapSetup(t, false)
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
for i, text := range []string{"bonjour", "et ensuite ?"} {
|
||||
if i == 1 {
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
want := oldAssembly(caps, append(append([]Message(nil), c.Messages...), um(text)))
|
||||
c.mu.Unlock()
|
||||
body := playTurn(t, m, c, caps, text)
|
||||
var got struct {
|
||||
Messages any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Messages, jsonValue(t, want)) {
|
||||
t.Fatalf("tour %d : requête différente de l'assemblage d'avant\nreçu %v\nattendu %v", i, got.Messages, jsonValue(t, want))
|
||||
}
|
||||
if strings.Contains(body, "context_update") || strings.Contains(body, "as of") {
|
||||
t.Fatalf("tour %d : trace du bloc figé sans la clé :\n%s", i, body)
|
||||
}
|
||||
}
|
||||
if us := userContents(t, m.all()[1]); !strings.Contains(us[0], "](docker.md)") {
|
||||
t.Fatalf("sans la clé, le bloc vivant doit montrer la nouvelle page : %q", us[0])
|
||||
}
|
||||
b, _ := json.Marshal(c)
|
||||
if c.ProjSnap != nil || strings.Contains(string(b), "proj_snap") {
|
||||
t.Fatalf("instantané persisté sans la clé : %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// La première page d'un projet vide arrive en mise à jour : le premier message
|
||||
// reste identique, la mise à jour est rangée dans l'historique et renvoyée telle
|
||||
// quelle ensuite, la suivante n'annonce que la page ajoutée.
|
||||
func TestProjSnapPageAjouteeEnMiseAJour(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
b1 := playTurn(t, m, c, caps, "bonjour")
|
||||
if !strings.Contains(systemContent(t, b1), projSnapSystemLine) {
|
||||
t.Fatal("ligne fixe du système absente avec la clé")
|
||||
}
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b2 := playTurn(t, m, c, caps, "et le NAS ?")
|
||||
u1, u2 := userContents(t, b1), userContents(t, b2)
|
||||
if u2[0] != u1[0] {
|
||||
t.Fatalf("premier message changé :\navant %q\naprès %q", u1[0], u2[0])
|
||||
}
|
||||
last := u2[len(u2)-1]
|
||||
if !strings.HasPrefix(last, ctxUpdOpen) || !strings.Contains(last, "](nas.md)") || !strings.HasSuffix(last, "et le NAS ?") {
|
||||
t.Fatalf("mise à jour attendue en tête du nouveau message : %q", last)
|
||||
}
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b3 := playTurn(t, m, c, caps, "et docker ?")
|
||||
u3 := userContents(t, b3)
|
||||
if u3[0] != u1[0] || u3[1] != u2[1] {
|
||||
t.Fatalf("messages déjà envoyés modifiés :\n%q\n%q", u3[0], u3[1])
|
||||
}
|
||||
last = u3[len(u3)-1]
|
||||
if !strings.Contains(last, "+ [Docker](docker.md)") || strings.Contains(last, "nas.md") {
|
||||
t.Fatalf("seule la page ajoutée doit être annoncée : %q", last)
|
||||
}
|
||||
// Sans changement : rien de posé.
|
||||
b4 := playTurn(t, m, c, caps, "merci")
|
||||
if u4 := userContents(t, b4); u4[len(u4)-1] != "merci" {
|
||||
t.Fatalf("mise à jour vide posée : %q", u4[len(u4)-1])
|
||||
}
|
||||
// Persisté et relu : l'état annoncé survit.
|
||||
raw, _ := json.Marshal(c)
|
||||
c2 := newTestConv()
|
||||
c2.loadFrom("x", raw)
|
||||
if c2.ProjSnap == nil || c2.ProjSnap.State.Mem["docker.md"] == "" {
|
||||
t.Fatalf("instantané non relu : %+v", c2.ProjSnap)
|
||||
}
|
||||
}
|
||||
|
||||
// Une nouvelle valeur de tracker remplace sa ligne (« ~ ») ; un tracker supprimé
|
||||
// est annoncé comme tel.
|
||||
func TestProjSnapTrackerLigneRemplacee(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
if _, err := trackerAdd("poids", "2026-10-01", "72.4 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
b1 := playTurn(t, m, c, caps, "salut")
|
||||
if u := userContents(t, b1); !strings.Contains(u[0], "Trackers (tracker tool) as of ") || strings.Contains(u[0], "answer straight from this") {
|
||||
t.Fatalf("en-tête figé attendu : %q", u[0])
|
||||
}
|
||||
if _, err := trackerAdd("poids", "2026-10-03", "73 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b2 := playTurn(t, m, c, caps, "mon poids ?")
|
||||
u := userContents(t, b2)
|
||||
if u[0] != userContents(t, b1)[0] {
|
||||
t.Fatal("premier message changé")
|
||||
}
|
||||
if !strings.Contains(u[len(u)-1], "~ poids — 73 kg (2026-10-03)") {
|
||||
t.Fatalf("ligne de tracker attendue : %q", u[len(u)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// Une compaction (début, fin, manuelle) reprend le bloc tout neuf au tour suivant
|
||||
// et retire les mises à jour de la queue gardée : une vieille valeur ne doit pas
|
||||
// suivre la fraîche.
|
||||
func TestProjSnapCompactionRafraichit(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
if _, err := trackerAdd("poids", "2026-10-01", "72.4 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "salut")
|
||||
if _, err := trackerAdd("poids", "2026-10-02", "72.8 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playTurn(t, m, c, caps, "note-le")
|
||||
if !hasContextUpdates(c.Messages) {
|
||||
t.Fatal("mise à jour attendue dans l'historique")
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600)))
|
||||
}
|
||||
// Le message avec la mise à jour en dernier : il tombe dans la queue gardée.
|
||||
c.Messages = append(c.Messages, am("vu"), um(ctxUpdOpen+` at="x">`+"\n~ poids — 72.4 kg\n"+ctxUpdClose+"\n\nencore"))
|
||||
if _, changed := c.compactAndPublish(context.Background(), c.epoch, "test", append([]Message(nil), c.Messages...), 50000, caps); !changed {
|
||||
t.Fatal("compaction sans effet")
|
||||
}
|
||||
if c.ProjSnap != nil || hasContextUpdates(c.Messages) {
|
||||
t.Fatalf("après compaction : instantané %v, mises à jour restantes %v", c.ProjSnap != nil, hasContextUpdates(c.Messages))
|
||||
}
|
||||
b := playTurn(t, m, c, caps, "et maintenant ?")
|
||||
if strings.Contains(strings.Join(userContents(t, b), " "), ctxUpdOpen) {
|
||||
t.Fatalf("mise à jour après le bloc neuf : %s", b)
|
||||
}
|
||||
if u := userContents(t, b); !strings.Contains(u[0], "72.8 kg") {
|
||||
t.Fatalf("bloc neuf attendu avec la dernière valeur : %q", u[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Compaction EN COURS de tour : le bloc vivant remplace le figé dans la requête
|
||||
// rejouée (la mise à jour avalée par le résumé n'est pas perdue), et c'est lui
|
||||
// que le tour suivant renvoie.
|
||||
func TestProjSnapCompactionEnTour(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "salut")
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 12; i++ {
|
||||
c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600)))
|
||||
}
|
||||
c.Messages = append(c.Messages, am("vu"))
|
||||
n := len(m.all())
|
||||
m.mu.Lock()
|
||||
m.reply = func(k int, _ string) (int, string) {
|
||||
if k == n {
|
||||
return 400, `{"error":{"code":400,"message":"request (99999 tokens) exceeds the available context size"}}`
|
||||
}
|
||||
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
playTurn(t, m, c, caps, "et le NAS ?")
|
||||
all := m.all()
|
||||
first, retry := all[n], all[n+1]
|
||||
if uf := userContents(t, first); !strings.HasPrefix(uf[len(uf)-1], ctxUpdOpen) {
|
||||
t.Fatal("mise à jour attendue dans la requête refusée")
|
||||
}
|
||||
u := userContents(t, retry)
|
||||
if strings.Contains(strings.Join(u, " "), ctxUpdOpen) {
|
||||
t.Fatalf("mise à jour restée après la compaction en cours de tour : %q", u)
|
||||
}
|
||||
if !strings.Contains(u[0], "](nas.md)") {
|
||||
t.Fatalf("la page doit être dans le bloc rafraîchi : %q", u[0])
|
||||
}
|
||||
if c.ProjSnap == nil || c.ProjSnap.State.Mem["nas.md"] == "" || hasContextUpdates(c.Messages) {
|
||||
t.Fatalf("instantané rafraîchi non rangé : %+v", c.ProjSnap)
|
||||
}
|
||||
next := playTurn(t, m, c, caps, "merci")
|
||||
head := func(s string) string { return s[:strings.Index(s, "</project_context>")] }
|
||||
if head(userContents(t, next)[0]) != head(u[0]) {
|
||||
t.Fatal("le tour suivant doit renvoyer le bloc rafraîchi à l'identique")
|
||||
}
|
||||
}
|
||||
|
||||
// Changer de projet, de discussion ou repartir à zéro ne garde pas l'instantané.
|
||||
func TestProjSnapProjetEtDiscussion(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "salut")
|
||||
p, err := createProject("Jardin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setProjectDesc(p.Slug, "Le potager."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setActiveProject(p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := playTurn(t, m, c, caps, "et le jardin ?")
|
||||
u := userContents(t, b)
|
||||
if !strings.Contains(u[0], "Le potager.") || strings.Contains(strings.Join(u, " "), ctxUpdOpen) {
|
||||
t.Fatalf("changement de projet : bloc neuf attendu, sans mise à jour : %q", u[0])
|
||||
}
|
||||
if c.ProjSnap == nil {
|
||||
t.Fatal("instantané attendu")
|
||||
}
|
||||
// loadFrom : fil neuf, ou enregistré sans le champ.
|
||||
c.loadFrom("neuf", nil)
|
||||
if c.ProjSnap != nil {
|
||||
t.Fatal("fil neuf : instantané hérité")
|
||||
}
|
||||
c.ProjSnap = &projSnapshot{Key: "x"}
|
||||
c.loadFrom("ancien", []byte(`{"messages":[{"role":"user","content":"vieux"}]}`))
|
||||
if c.ProjSnap != nil {
|
||||
t.Fatal("fil enregistré sans instantané : hérité")
|
||||
}
|
||||
c.ProjSnap = &projSnapshot{Key: "x"}
|
||||
c.Reset()
|
||||
if c.ProjSnap != nil {
|
||||
t.Fatal("Reset garde l'instantané")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans agent : ni bloc ni mise à jour, et les blocs restés d'avant ne partent pas.
|
||||
func TestProjSnapSansAgent(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "salut")
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "le NAS ?")
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := c.ProjSnap.State
|
||||
b := playTurn(t, m, c, Caps{}, "réponds sans outils")
|
||||
if strings.Contains(b, "context_update") || strings.Contains(b, "project_context") {
|
||||
t.Fatalf("agent off : contexte projet envoyé : %s", b)
|
||||
}
|
||||
if last := c.Messages[len(c.Messages)-2]; msgText(last) != "réponds sans outils" {
|
||||
t.Fatalf("agent off : message modifié : %q", msgText(last))
|
||||
}
|
||||
if !reflect.DeepEqual(c.ProjSnap.State, before) {
|
||||
t.Fatal("agent off : état annoncé modifié")
|
||||
}
|
||||
}
|
||||
|
||||
// Clé retirée après usage : l'instantané et les blocs sont nettoyés, retour au
|
||||
// bloc vivant sans vieille mise à jour derrière lui.
|
||||
func TestProjSnapCleRetiree(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "salut")
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playTurn(t, m, c, caps, "le NAS ?")
|
||||
if err := SetConfigKey("PROJ_SNAPSHOT", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := playTurn(t, m, c, caps, "et après ?")
|
||||
if strings.Contains(b, "context_update") || strings.Contains(b, "as of") {
|
||||
t.Fatalf("restes du bloc figé : %s", b)
|
||||
}
|
||||
if c.ProjSnap != nil || hasContextUpdates(c.Messages) {
|
||||
t.Fatal("historique non nettoyé")
|
||||
}
|
||||
}
|
||||
|
||||
// Rafraîchissement : clé de validité (redémarrage, réglage moteur), système ou
|
||||
// outils changés, seuil d'accumulation.
|
||||
func TestProjSnapApplyRafraichit(t *testing.T) {
|
||||
projSnapSetup(t, true)
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
live := projSnapCapture(caps, "t0")
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{um("a")}
|
||||
if r, _ := c.projSnapApplyLocked(live, "k", "s", "t0"); !r {
|
||||
t.Fatal("premier tour : instantané attendu")
|
||||
}
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live2 := projSnapCapture(caps, "t1")
|
||||
c.Messages = append(c.Messages, am("b"), um("c"))
|
||||
if r, d := c.projSnapApplyLocked(live2, "k", "s", "t1"); r || d == "" {
|
||||
t.Fatalf("mise à jour attendue : refresh=%v delta=%q", r, d)
|
||||
}
|
||||
for _, tc := range []struct{ name, key, sys string }{{"clé", "k2", "s"}, {"système", "k", "s2"}} {
|
||||
snap := *c.ProjSnap
|
||||
c.Messages = append(c.Messages, am("d"), um("e"))
|
||||
if r, _ := c.projSnapApplyLocked(live, tc.key, tc.sys, "t2"); !r {
|
||||
t.Fatalf("%s changé : rafraîchissement attendu", tc.name)
|
||||
}
|
||||
if hasContextUpdates(c.Messages) {
|
||||
t.Fatalf("%s : mises à jour restées après rafraîchissement", tc.name)
|
||||
}
|
||||
c.ProjSnap = &snap
|
||||
}
|
||||
// Seuil : les mises à jour accumulées dépassent la limite.
|
||||
c.ProjSnap.Key, c.ProjSnap.SysHash = "k", "s"
|
||||
c.ProjSnap.DeltaTok = projSnapDeltaLimit(c.ProjSnap.SnapTok)
|
||||
c.ProjSnap.State = live.state
|
||||
if r, _ := c.projSnapApplyLocked(live2, "k", "s", "t3"); !r {
|
||||
t.Fatal("seuil dépassé : rafraîchissement attendu")
|
||||
}
|
||||
// La clé suit le lancement de Loki.
|
||||
k1 := projSnapKey(caps, ReadConfig())
|
||||
old := projSnapBoot
|
||||
projSnapBoot = "autre"
|
||||
defer func() { projSnapBoot = old }()
|
||||
if projSnapKey(caps, ReadConfig()) == k1 {
|
||||
t.Fatal("redémarrage : clé inchangée")
|
||||
}
|
||||
projSnapBoot = old
|
||||
if err := SetConfigKey("REASONING_EFFORT", "high"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projSnapKey(caps, ReadConfig()) != k1 {
|
||||
t.Fatal("un réglage de conversation ne doit pas rafraîchir")
|
||||
}
|
||||
if err := SetConfigKey("MODEL", "/models/autre.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projSnapKey(caps, ReadConfig()) == k1 {
|
||||
t.Fatal("changement de modèle : clé inchangée")
|
||||
}
|
||||
}
|
||||
|
||||
// La prose repart en entier ; les sections faites de lignes, ligne à ligne.
|
||||
func TestProjDeltaParType(t *testing.T) {
|
||||
projSnapSetup(t, true)
|
||||
old := projState{
|
||||
Code: "Repository instructions (AGENTS.md) — follow them:\n\nmake test", Desc: "Ancienne description.",
|
||||
MemOn: true, MemProse: "# Index", Mem: map[string]string{"a.md": "- [A](a.md)", "b.md": "- [B](b.md)"},
|
||||
Trk: map[string]string{"poids": "poids — 72 kg (2026-10-01)", "pas": "pas — 8000 (2026-10-01)"},
|
||||
}
|
||||
lv := projLive{
|
||||
state: projState{
|
||||
Code: "Repository instructions (AGENTS.md) — follow them:\n\ngo test ./...", Desc: "Nouvelle description,\nsur deux lignes.",
|
||||
MemOn: true, MemProse: "# Index", Mem: map[string]string{"a.md": "- [A](a.md) — accroche", "c.md": "- [C](c.md)"},
|
||||
Trk: map[string]string{"poids": "poids — 73 kg (2026-10-03)", "eau": "eau — 2 L (2026-10-03)"},
|
||||
},
|
||||
memOrder: []string{"a.md", "c.md"},
|
||||
trkOrder: []string{"poids", "eau"},
|
||||
}
|
||||
d := projDelta(old, lv, "2026-10-04 09:00")
|
||||
for _, want := range []string{
|
||||
ctxUpdOpen + ` at="2026-10-04 09:00">`,
|
||||
"go test ./...",
|
||||
"Nouvelle description,\nsur deux lignes.",
|
||||
"~ [A](a.md) — accroche", "+ [C](c.md)", "- b.md (deleted)",
|
||||
"~ poids — 73 kg (2026-10-03)", "+ eau — 2 L (2026-10-03)", "- pas — 8000 (2026-10-01) (deleted)",
|
||||
} {
|
||||
if !strings.Contains(d, want) {
|
||||
t.Fatalf("manque %q dans :\n%s", want, d)
|
||||
}
|
||||
}
|
||||
if strings.Contains(d, "make test") || strings.Contains(d, "Ancienne") {
|
||||
t.Fatalf("l'ancien texte ne doit pas repartir :\n%s", d)
|
||||
}
|
||||
// En-tête de l'index modifié : l'index repart en entier.
|
||||
lv.state.MemProse = "# Index (revu)"
|
||||
lv.memMsg = Message{Content: "Project memory index … # Index (revu)"}
|
||||
if d := projDelta(old, lv, "t"); !strings.Contains(d, "Current version, replaces any earlier one — Project memory index") {
|
||||
t.Fatalf("prose de l'index modifiée : index complet attendu :\n%s", d)
|
||||
}
|
||||
if projDelta(old, projLive{state: old}, "t") != "" {
|
||||
t.Fatal("rien de changé : mise à jour vide attendue")
|
||||
}
|
||||
}
|
||||
|
||||
// Les blocs sont retirés partout où le texte de l'utilisateur est lu : tâche du
|
||||
// vérificateur, titre, export, entrée du résumeur ; en texte et en multimodal.
|
||||
func TestProjSnapRetireDesLecteurs(t *testing.T) {
|
||||
testHome(t)
|
||||
blk := ctxUpdOpen + ` at="t">` + "\n+ [A](a.md)\n" + ctxUpdClose + "\n\n"
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{um(blk + "corrige le build"), am("ok")}
|
||||
if got := c.lastUserText(); got != "corrige le build" {
|
||||
t.Fatalf("tâche du vérificateur : %q", got)
|
||||
}
|
||||
if got := convSummary(c.Messages); got != "corrige le build" {
|
||||
t.Fatalf("titre : %q", got)
|
||||
}
|
||||
out, err := c.ExportJSON(exportOpts{})
|
||||
if err != nil || strings.Contains(string(out), "context_update") {
|
||||
t.Fatalf("export : %v %s", err, out)
|
||||
}
|
||||
multi := []Message{
|
||||
{Role: "user", Content: []any{map[string]any{"type": "text", "text": blk}, map[string]any{"type": "text", "text": "vois l'image"}}},
|
||||
{Role: "user", Content: []map[string]any{{"type": "text", "text": blk + "suite"}}},
|
||||
}
|
||||
got := stripContextUpdates(multi)
|
||||
if hasContextUpdates(got) || len(got[0].Content.([]any)) != 1 || got[1].Content.([]map[string]any)[0]["text"] != "suite" {
|
||||
t.Fatalf("multimodal : %+v", got)
|
||||
}
|
||||
if hasContextUpdates(multi) == false {
|
||||
t.Fatal("l'original ne doit pas être modifié")
|
||||
}
|
||||
// Un texte qui en parle plus loin n'est pas touché.
|
||||
plain := []Message{um("explique " + blk)}
|
||||
if s := stripContextUpdates(plain); msgText(s[0]) != msgText(plain[0]) {
|
||||
t.Fatal("bloc hors tête retiré")
|
||||
}
|
||||
// Mise à jour posée puis retirée : retour exact au texte.
|
||||
for _, m0 := range []Message{um("x"), {Role: "user", Content: []any{map[string]any{"type": "text", "text": "vois"}}}} {
|
||||
m1, ok := prependCtxUpdate(m0, blk)
|
||||
if !ok {
|
||||
t.Fatal("pose refusée")
|
||||
}
|
||||
m2, _ := withoutCtxUpdate(m1)
|
||||
if !reflect.DeepEqual(jsonValue(t, m2), jsonValue(t, m0)) {
|
||||
t.Fatalf("aller-retour : %+v", m2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Valeurs de la clé ; un preset externe n'est jamais concerné.
|
||||
func TestProjSnapEnabled(t *testing.T) {
|
||||
for v, want := range map[string]bool{"": false, "off": false, "0": false, "on": true, "1": true, "oui": true, " ON ": true} {
|
||||
if got := projSnapEnabled(map[string]string{"PROJ_SNAPSHOT": v}); got != want {
|
||||
t.Fatalf("%q : %v", v, got)
|
||||
}
|
||||
}
|
||||
if projSnapEnabled(map[string]string{"PROJ_SNAPSHOT": "on", extKeyFlag: "1"}) {
|
||||
t.Fatal("preset externe : jamais de bloc figé")
|
||||
}
|
||||
}
|
||||
|
||||
// replaceProjectHead garde le rappel des pages lues, après le bloc neuf.
|
||||
func TestReplaceProjectHead(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: "base"},
|
||||
{Role: "system", Content: projectContextPrefix + " — vieux"},
|
||||
{Role: "system", Content: memIndexPrefix + " vieux"},
|
||||
{Role: "system", Content: memReminderPrefix + " a.md"},
|
||||
um("q"),
|
||||
}
|
||||
block := []Message{{Role: "system", Content: memIndexPrefix + " neuf"}}
|
||||
out := replaceProjectHead(msgs, block)
|
||||
var got []string
|
||||
for _, m := range out {
|
||||
got = append(got, msgText(m))
|
||||
}
|
||||
want := []string{"base", memIndexPrefix + " neuf", memReminderPrefix + " a.md", "q"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
// Bloc vide au départ : inséré après le système commun.
|
||||
out = replaceProjectHead([]Message{{Role: "system", Content: "base"}, um("q")}, block)
|
||||
if msgText(out[1]) != memIndexPrefix+" neuf" || msgText(out[2]) != "q" {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,9 @@ func convSummary(msgs []Message) string {
|
||||
if m.Role != "user" || isLokiInjected(m) {
|
||||
continue
|
||||
}
|
||||
// Le titre vient de ce qu'a écrit l'utilisateur, pas d'une mise à jour
|
||||
// du projet posée en tête par Loki (PROJ_SNAPSHOT).
|
||||
m, _ = withoutCtxUpdate(m)
|
||||
s, _ := m.Content.(string)
|
||||
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
||||
if s == "" {
|
||||
|
||||
@@ -127,6 +127,13 @@ func baseSystemPrompt(caps Caps, tools []Tool) string {
|
||||
if caps.Agent {
|
||||
b.WriteString(mcpPromptLine(tools))
|
||||
}
|
||||
// PROJ_SNAPSHOT (chat_projsnap.go) : le bloc projet est figé, ses changements
|
||||
// arrivent en tête des messages suivants. Une ligne fixe, seulement avec la
|
||||
// clé, pour qu'ils ne passent ni pour la parole de l'utilisateur ni pour
|
||||
// moins récents que le bloc.
|
||||
if caps.Agent && projSnapEnabled(ReadConfig()) {
|
||||
b.WriteString(projSnapSystemLine)
|
||||
}
|
||||
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -156,13 +156,13 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
|
||||
|
||||
// Même contexte que le tour de build : projet (description, index mémoire,
|
||||
// trackers) et consignes du dépôt. Sans eux, la reprise changeait le début
|
||||
// du premier message utilisateur — tout le cache de prompt à refaire.
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
// du premier message utilisateur — tout le cache de prompt à refaire. Même
|
||||
// assemblage aussi (turnView) : avec PROJ_SNAPSHOT, le bloc figé de la
|
||||
// discussion, et ses changements en tête de la consigne.
|
||||
sent, tools, snapTurn := c.turnView(caps, epoch, msgs, true)
|
||||
runCtx := ctx
|
||||
if snapTurn != nil {
|
||||
runCtx = withProjSnapTurn(ctx, snapTurn)
|
||||
}
|
||||
var content strings.Builder
|
||||
sawUsage := false
|
||||
@@ -171,8 +171,7 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
|
||||
// raisonnement de la réponse finale (REASONING_ECHO).
|
||||
var newBase []Message
|
||||
var echo *ReasoningEcho
|
||||
sent, tools := prepareTurn(final, caps)
|
||||
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
extra, _ := runChatTools(runCtx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 {
|
||||
sawUsage = true
|
||||
}
|
||||
@@ -206,6 +205,9 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
|
||||
if newBase != nil {
|
||||
c.Messages = newBase
|
||||
}
|
||||
if s := snapTurn.takeRefreshed(); s != nil {
|
||||
c.ProjSnap = s // même règle que generate
|
||||
}
|
||||
c.Messages = append(c.Messages, extra...)
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, withEcho(Message{Role: "assistant", Content: s}, echo))
|
||||
@@ -300,7 +302,9 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int, isolated bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// lastUserText retrouve le dernier message utilisateur du fil (la tâche).
|
||||
// lastUserText retrouve le dernier message utilisateur du fil (la tâche), sans
|
||||
// la mise à jour du projet que Loki a pu poser en tête (PROJ_SNAPSHOT) : le
|
||||
// vérificateur la prendrait pour une partie de la demande.
|
||||
func (c *Conversation) lastUserText() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -309,7 +313,8 @@ func (c *Conversation) lastUserText() string {
|
||||
if c.Messages[i].Role != "user" || isLokiInjected(c.Messages[i]) {
|
||||
continue
|
||||
}
|
||||
if s, ok := c.Messages[i].Content.(string); ok {
|
||||
m, _ := withoutCtxUpdate(c.Messages[i])
|
||||
if s, ok := m.Content.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,6 +1130,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// que décrit le préambule, à l'octet près.
|
||||
func runChatTools(ctx context.Context, messages []Message, tools []Tool, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
|
||||
var extra []Message
|
||||
// Bloc projet figé de la discussion (PROJ_SNAPSHOT, chat_projsnap.go) : nil
|
||||
// hors tour de discussion avec la clé. Masqué pour la suite du contexte —
|
||||
// un sous-agent lancé par un outil a son propre fil, pas ce bloc.
|
||||
snapTurn := projSnapTurnFrom(ctx)
|
||||
if snapTurn != nil {
|
||||
ctx = withProjSnapTurn(ctx, nil)
|
||||
}
|
||||
// newHistory publie l'historique réécrit en cours de tour (compaction,
|
||||
// réduction). Le début du prompt change de toute façon : avec la clé, le
|
||||
// bloc figé y est remplacé par le vivant et les mises à jour retirées.
|
||||
newHistory := func() {
|
||||
if snapTurn != nil {
|
||||
messages = snapTurn.refresh(messages)
|
||||
}
|
||||
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
|
||||
}
|
||||
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
|
||||
// in streaming mode: the model's <think> block (opened by the chat template)
|
||||
// arrives inline in `content`, terminated by a literal </think>. When
|
||||
@@ -1431,7 +1447,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
// secours ne survit pas à la fin du tour et le prompt re-déborde au
|
||||
// message suivant.
|
||||
extra = nil
|
||||
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
|
||||
newHistory()
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -1444,7 +1460,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
logCompact("réduction", overflowTokens(msg), messages, c, changed)
|
||||
messages = c
|
||||
extra = nil
|
||||
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
|
||||
newHistory()
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -2433,7 +2449,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
// repart d'un `extra` vide, sinon l'appelant la ré-empilerait avec
|
||||
// les messages du tour et dupliquerait tout.
|
||||
extra = nil
|
||||
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
|
||||
newHistory()
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -2477,7 +2493,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
|
||||
cb(StreamEvent{DropReasoning: true})
|
||||
messages = c
|
||||
extra = nil
|
||||
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
|
||||
newHistory()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
+37
-10
@@ -33,20 +33,42 @@ const memIndexPrefix = "Project memory index"
|
||||
// proactif ou si l'index est vide. On n'injecte que l'INDEX (titres + accroches),
|
||||
// jamais le contenu des pages — l'IA fait mem_read pour lire une page.
|
||||
func memIndexMessage() (Message, bool) {
|
||||
if memMode() != MemAlways {
|
||||
idx, ok := memIndexText()
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
return renderMemIndex(idx, ""), true
|
||||
}
|
||||
|
||||
// memIndexText lit l'index à injecter : ok=false hors mode mémoire proactif ou
|
||||
// sans aucune page. Séparé du rendu pour que le bloc figé (chat_projsnap.go)
|
||||
// déduise son état de la MÊME lecture que le texte qu'il envoie.
|
||||
func memIndexText() (string, bool) {
|
||||
if memMode() != MemAlways {
|
||||
return "", false
|
||||
}
|
||||
idx := strings.TrimSpace(MemContent(memIndexFile))
|
||||
// Un index qui n'a que son en-tête ne dit rien au modèle et coûte du contexte
|
||||
// à chaque tour : on attend au moins une VRAIE ligne de page. Le test est fait
|
||||
// sur le début de ligne, parce que l'en-tête cite le format `- [titre](…)` en
|
||||
// exemple — une simple recherche du motif le prendrait pour une page.
|
||||
if idx == "" || !hasIndexedPage(idx) {
|
||||
return Message{}, false
|
||||
return "", false
|
||||
}
|
||||
content := memIndexPrefix + " for project \"" + projectName(activeProjectSlug()) +
|
||||
"\" — the pages you can open with mem_read (only titles/hooks here, not their content). Auto-maintained.\n\n" + idx
|
||||
return Message{Role: "system", Content: content}, true
|
||||
return idx, true
|
||||
}
|
||||
|
||||
// renderMemIndex : le message d'index. asOf vide = la forme vivante, à jour à
|
||||
// chaque tour (texte d'avant PROJ_SNAPSHOT, à l'octet près) ; sinon la copie
|
||||
// figée, datée, que les <context_update> de Loki complètent ensuite.
|
||||
func renderMemIndex(idx, asOf string) Message {
|
||||
head := memIndexPrefix + " for project \"" + projectName(activeProjectSlug()) + "\""
|
||||
tail := " — the pages you can open with mem_read (only titles/hooks here, not their content). Auto-maintained."
|
||||
if asOf != "" {
|
||||
head += " as of " + asOf
|
||||
tail += memIndexFrozenTail
|
||||
}
|
||||
return Message{Role: "system", Content: head + tail + "\n\n" + idx}
|
||||
}
|
||||
|
||||
// projectContextPrefix marque le message de contexte projet injecté (description),
|
||||
@@ -59,14 +81,18 @@ const projectContextPrefix = "Project context"
|
||||
// dans quel projet elle est via l'index mémoire, sans laïus. Indépendant du mode
|
||||
// mémoire — une description reste utile même mémoire coupée.
|
||||
func projectContextMessage() (Message, bool) {
|
||||
slug := activeProjectSlug()
|
||||
desc := strings.TrimSpace(projectDesc(slug))
|
||||
desc := strings.TrimSpace(projectDesc(activeProjectSlug()))
|
||||
if desc == "" {
|
||||
return Message{}, false
|
||||
}
|
||||
return renderProjectContext(desc), true
|
||||
}
|
||||
|
||||
// renderProjectContext : le message de description, pour une description non vide.
|
||||
func renderProjectContext(desc string) Message {
|
||||
content := projectContextPrefix + " — you are working within the project \"" +
|
||||
projectName(slug) + "\". What this project is about (set by the user):\n\n" + desc
|
||||
return Message{Role: "system", Content: content}, true
|
||||
projectName(activeProjectSlug()) + "\". What this project is about (set by the user):\n\n" + desc
|
||||
return Message{Role: "system", Content: content}
|
||||
}
|
||||
|
||||
// projectSystemMessages renvoie, dans l'ordre, tout ce qui situe l'IA dans son
|
||||
@@ -82,7 +108,8 @@ func projectContextMessage() (Message, bool) {
|
||||
//
|
||||
// Le prix est une invalidation du cache de prompt quand l'index change, c'est-à-
|
||||
// dire quand une page est créée ou supprimée : exactement les moments où le
|
||||
// modèle DOIT voir la nouvelle liste.
|
||||
// modèle DOIT voir la nouvelle liste. PROJ_SNAPSHOT=on (chat_projsnap.go) évite
|
||||
// ce prix pour la discussion : bloc figé, changements livrés en <context_update>.
|
||||
func projectSystemMessages() []Message {
|
||||
var out []Message
|
||||
if m, ok := projectContextMessage(); ok {
|
||||
|
||||
@@ -102,6 +102,10 @@ var configTemplate = []struct{ key, help string }{
|
||||
"les étapes d'une boucle d'outils se relisent avec leur réflexion au lieu de blocs vides, et le moteur ne recalcule " +
|
||||
"plus le dernier message ; plus de contexte par tour, compaction plus tôt. Vide/off (défaut) = rien n'est renvoyé. " +
|
||||
"Jamais vers une API externe ni vers un autre modèle ; suspendu de lui-même si le gabarit le refuse"},
|
||||
{"PROJ_SNAPSHOT", "on = figer le bloc projet (description, index mémoire, trackers, AGENTS.md) par discussion et livrer " +
|
||||
"ses changements en <context_update> en tête du message suivant : une page créée ou une valeur de tracker ne fait plus " +
|
||||
"recalculer toute la conversation. Repris tout neuf à chaque compaction, redémarrage ou changement de modèle/projet. " +
|
||||
"Vide/off (défaut) = bloc reconstruit à chaque tour ; sans effet sur un preset externe"},
|
||||
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
|
||||
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
|
||||
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
|
||||
|
||||
+36
-11
@@ -498,23 +498,48 @@ const trackerIndexPrefix = "Trackers (tracker tool)"
|
||||
// la dernière valeur — SANS appeler l'outil : elle ne l'appelle que pour fouiller
|
||||
// l'historique ou pour modifier. Quelques lignes, jamais le contenu.
|
||||
func trackerIndexMessage() (Message, bool) {
|
||||
if memMode() != MemAlways {
|
||||
list, ok := trackerIndexList()
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
return renderTrackerIndex(list, ""), true
|
||||
}
|
||||
|
||||
// trackerIndexList : les trackers à annoncer, ok=false hors mode mémoire
|
||||
// proactif ou s'il n'y en a aucun. Séparé du rendu pour le bloc figé
|
||||
// (chat_projsnap.go), qui en tire aussi son état ligne à ligne.
|
||||
func trackerIndexList() ([]trackerMeta, bool) {
|
||||
if memMode() != MemAlways {
|
||||
return nil, false
|
||||
}
|
||||
list := trackerList()
|
||||
if len(list) == 0 {
|
||||
return Message{}, false
|
||||
return list, len(list) > 0
|
||||
}
|
||||
|
||||
// trackerIndexLine : la ligne d'un tracker, « nom — dernière valeur (date) ».
|
||||
func trackerIndexLine(m trackerMeta) string {
|
||||
last := ""
|
||||
if m.last != nil {
|
||||
last = " — " + m.last.Text + " (" + fmtEvent(*m.last) + ")"
|
||||
}
|
||||
return m.Name + last
|
||||
}
|
||||
|
||||
// renderTrackerIndex : le message d'index des trackers. asOf vide = la forme
|
||||
// vivante (texte d'avant PROJ_SNAPSHOT, à l'octet près). Sinon la copie figée :
|
||||
// datée, et sans « réponds directement d'ici », puisque des <context_update>
|
||||
// plus récents peuvent en remplacer des lignes.
|
||||
func renderTrackerIndex(list []trackerMeta, asOf string) Message {
|
||||
var b strings.Builder
|
||||
b.WriteString(trackerIndexPrefix + " — this is the COMPLETE list of your trackers, each with its LATEST point. To say which trackers exist or give a latest value, answer straight from this — do NOT call tracker with no arguments to list them, it would only repeat this. Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n")
|
||||
for _, m := range list {
|
||||
last := ""
|
||||
if m.last != nil {
|
||||
last = " — " + m.last.Text + " (" + fmtEvent(*m.last) + ")"
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s%s\n", m.Name, last)
|
||||
if asOf == "" {
|
||||
b.WriteString(trackerIndexPrefix + " — this is the COMPLETE list of your trackers, each with its LATEST point. To say which trackers exist or give a latest value, answer straight from this — do NOT call tracker with no arguments to list them, it would only repeat this. Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n")
|
||||
} else {
|
||||
b.WriteString(trackerIndexPrefix + " as of " + asOf + trackerFrozenHead)
|
||||
}
|
||||
return Message{Role: "system", Content: strings.TrimRight(b.String(), "\n")}, true
|
||||
for _, m := range list {
|
||||
fmt.Fprintf(&b, "- %s\n", trackerIndexLine(m))
|
||||
}
|
||||
return Message{Role: "system", Content: strings.TrimRight(b.String(), "\n")}
|
||||
}
|
||||
|
||||
// --- outil modèle -------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user