Files
Loki/internal/loki/tool_results.go
T
MichaelandClaude Opus 5.5 c3005f5b56 Persistance : le fsync de la discussion ne retarde plus le premier token
StartTurn persistait AVANT de lancer la génération : un marshal, puis deux
commits bbolt (contenu, puis index), soit deux fsync et plusieurs ouvertures
de base — 26-30 ms mesurés sur un NVMe Windows, bien plus sur un /mnt/user
d'Unraid ou un disque de parité, payés à chaque message avant que la requête
ne parte vers llama-server. Même facture au milieu d'un tour (vérification du
mode Code) et pour chaque résultat d'outil coupé (« voir plus »).

- chat_persist.go : un écrivain unique et ordonné. L'instantané reste pris
  sous c.mu (images → références, marshal, identifiant de la discussion) ;
  seule l'E/S part en différé. Le dernier instantané de chaque discussion
  l'emporte (numéro pris sous c.mu), les lots sont fusionnés en UNE
  transaction : contenu + index ensemble, résultats d'outils compris.
- Seuls StartTurn, la persistance en cours de tour et les résultats d'outils
  sont asynchrones. Fin de tour, reset, bascules, compactage manuel restent
  synchrones et attendent tout ce qui précède ; la suppression écarte puis
  attend les écritures qui la visent (plus de discussion ressuscitée).
- Discussion active gardée en RAM, créée sous verrou (plus de double
  identifiant sur base neuve) et changée sous c.mu avec le contenu : un
  instantané ne peut plus écrire l'ancien fil sous le nouvel identifiant.
- « Voir plus » servi depuis la mémoire tant que le résultat n'est pas écrit ;
  verrouillage vérifié avant de rendre un id.
- En plein tour, la base reçoit le journal sous sa forme de fin de tour
  (compactLog, version pure de compactLogLocked) ; la mémoire n'est pas touchée.
- Vidage de l'écrivain avant redémarrage, « Quitter », exec et (dé)chiffrement.

Le contexte vu par le modèle ne change pas : il lit c.Messages en mémoire, les
résultats complets d'outils et la compaction du journal ne servent qu'à l'UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:19:03 +02:00

136 lines
4.1 KiB
Go

package loki
import (
"crypto/rand"
"encoding/hex"
"strings"
"sync/atomic"
bolt "go.etcd.io/bbolt"
)
// tool_results.go — résultats COMPLETS des outils, pour le bouton « voir plus ».
// Repris d'AJEAN 0.15.5 / 0.15.7. Chiffré comme les discussions quand le
// chiffrement mémoire est actif (bkToolRes figure dans encryptedBuckets).
//
// Le flux n'envoie à l'UI qu'un aperçu (toolPreviewChars) ; le reste se charge
// au clic. Il ne peut pas être cherché dans conv.Messages : pendant un tour
// d'agent, les messages tool n'y sont versés qu'à la FIN du tour, et après un
// compactage les anciens ont disparu de la vue modèle. On garde donc chaque
// résultat coupé dans un bucket dédié, sous un id PROPRE (pas le tool_call_id,
// que certains parseurs réutilisent d'un tour à l'autre).
//
// Rangement PAR DISCUSSION : la clé est « <id de discussion>.<aléa> ». Les
// résultats vivent aussi longtemps que leur discussion (supprimés avec elle,
// voir deleteToolResultsFor) ; les orphelins sont nettoyés de temps en temps.
const (
bkToolRes = "toolres"
toolResPruneGap = 200 // nettoyage tenté toutes les N écritures
)
var toolResWrites atomic.Int64
func toolResID(sid string) string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
if sid = strings.TrimSpace(sid); sid == "" {
sid = "nosession"
}
return sid + "." + hex.EncodeToString(b[:])
}
// saveToolResult enregistre un résultat complet de la discussion ACTIVE et
// renvoie son id ("" en cas d'échec : l'appelant envoie alors le résultat
// entier dans le flux).
//
// L'écriture est confiée à l'écrivain de la discussion (chat_persist.go) : un
// tour d'agent qui coupe vingt résultats payait vingt fsync entre l'outil et
// l'étape suivante. Le résultat est servi depuis la mémoire jusqu'à ce qu'il
// soit sur disque (loadToolResult).
func saveToolResult(result string) string {
id := toolResID(convEnsureActive())
// Chiffrement actif mais mémoire verrouillée : on refuse d'écrire en clair,
// et l'appelant envoie alors le résultat entier dans le flux. Vérifié ICI,
// pas à l'écriture : un id rendu doit désigner un résultat qu'on écrira.
if id == "" {
return ""
}
if _, err := memEncoderNow(); err != nil {
return ""
}
persistQ.enqueueToolRes(toolResJob{path: dbPath(), key: id, plain: result})
if toolResWrites.Add(1)%toolResPruneGap == 0 {
go pruneToolResults()
}
return id
}
// loadToolResult relit un résultat enregistré.
func loadToolResult(id string) (string, bool) {
if strings.ContainsAny(id, "/\\") {
return "", false
}
if s, ok := persistQ.toolResPending(id); ok {
return s, true
}
b, err := getStoreBytesErr(bkToolRes, id)
if err != nil || b == nil {
return "", false
}
return string(b), true
}
// deleteToolResultsFor supprime les résultats d'une discussion supprimée.
func deleteToolResultsFor(sid string) {
if sid == "" {
return
}
prefix := sid + "."
persistQ.dropToolRes(prefix)
_ = update(bkToolRes, func(b *bolt.Bucket) error {
c := b.Cursor()
var keys [][]byte
for k, _ := c.Seek([]byte(prefix)); k != nil && strings.HasPrefix(string(k), prefix); k, _ = c.Next() {
keys = append(keys, append([]byte(nil), k...))
}
for _, k := range keys {
if err := b.Delete(k); err != nil {
return err
}
}
return nil
})
}
// pruneToolResults supprime les résultats de discussions qui n'existent plus.
func pruneToolResults() {
// Mémoire verrouillée : l'index des discussions est illisible et reviendrait
// vide — tout passerait pour orphelin. On attend le déverrouillage.
if memEncActive() && !memUnlocked() {
return
}
alive := map[string]bool{getStr(bkChat, ckActive): true}
for _, m := range convIndex() {
alive[m.ID] = true
}
_ = update(bkToolRes, func(b *bolt.Bucket) error {
var orphans []string
_ = b.ForEach(func(k, _ []byte) error {
key := string(k)
if i := strings.LastIndexByte(key, '.'); i > 0 && key[:i] != "nosession" && !alive[key[:i]] {
orphans = append(orphans, key)
}
return nil
})
for _, k := range orphans {
if err := b.Delete([]byte(k)); err != nil {
return err
}
}
return nil
})
}