mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
- LSP (lsp.go) : un signal resté dans le tampon par une republication tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même que le serveur ait vu la nouvelle version. Le canal est vidé, une publication d'une version antérieure est ignorée, et check attend 350 ms de calme après la dernière publication (liste vide puis analyse : c'est l'analyse qui compte), toujours borné par lspDiagWait. Au passage : interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par check) — dead devient atomique. - run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui l'effaçait (AJEAN 7403a8a0). - Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par toute écriture du bucket d'état, par la date/taille du fichier (autre process) et au bout d'une seconde. - MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil, plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8). - Recall : les blocs d'une compaction s'archivent en une seule transaction au lieu d'une par bloc (AJEAN #130). - Compactage en cours de tour : après un essai qui n'a rien réduit, pas de nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN 68659e73). - Motifs de relance : un motif vide (qui correspondait à tout) est écarté, une surcharge sans motif utilisable retombe sur l'embarqué, et un changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869, c52c5bf7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
247 lines
8.2 KiB
Go
247 lines
8.2 KiB
Go
package loki
|
||
|
||
// code_retry.go — filets « auto-retry » (repris des auto-retry patterns
|
||
// d'OpenFox, réécrits en Go — voir NOTICE.md) : quand le modèle termine son
|
||
// tour sur un texte qui contient un APPEL D'OUTIL TEXTUEL (halluciné, donc
|
||
// jamais exécuté), on relance le tour UNE fois avec une consigne corrective au
|
||
// lieu de laisser l'utilisateur lire un pseudo-JSON d'appel d'outil.
|
||
//
|
||
// Typique des petits modèles très quantifiés : `default_api:bash`,
|
||
// `<tool_call>{...}</tool_call>`, un bloc ```tool_code```… Le modèle VOULAIT
|
||
// agir ; il a juste raté la syntaxe du protocole. Le nudge « pensé sans agir »
|
||
// existant ne les attrapait pas : le tour a bien produit du contenu.
|
||
|
||
import (
|
||
"encoding/json"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
|
||
// serrés — un faux positif relancerait un tour parfaitement bon.
|
||
var defaultRetryPatterns = []string{
|
||
`<tool_call>`,
|
||
`</tool_call>`,
|
||
`<\|tool_call\|>`,
|
||
"```tool_code",
|
||
"```tool_call",
|
||
`\bdefault_api[.:]\w+`,
|
||
`\bfunctions\.\w+\s*\(`,
|
||
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
|
||
`\[TOOL_REQUEST\]`,
|
||
// Format XML de Qwen3-Coder, quand le gabarit du moteur ne l'a pas converti
|
||
// en tool_calls (OpenFox 2.0.15x) : <function=bash><parameter=command>…
|
||
`<function=\w+>`,
|
||
`<parameter=\w+>`,
|
||
}
|
||
|
||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
||
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
|
||
// choisi son rôle (CLI, serveur…).
|
||
//
|
||
// Relus quand la surcharge change (OpenFox c52c5bf7) : un sync.Once les
|
||
// figeait jusqu'au redémarrage du process. retryPatterns tourne pendant le flux,
|
||
// à chaque morceau : la base n'est rouverte que si une écriture de ce process a
|
||
// touché le bucket d'état (retryStateBust, via cacheBust) ou, au plus une fois
|
||
// par seconde, si date ou taille du fichier trahissent l'écriture d'un autre
|
||
// process (`loki config set` pendant que le service tourne).
|
||
var (
|
||
retryMu sync.Mutex
|
||
retryRes []*regexp.Regexp
|
||
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
|
||
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
|
||
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
|
||
retryCustom bool
|
||
retryLoaded bool
|
||
retryStale bool // écriture locale du bucket d'état depuis la lecture
|
||
retryRaw string
|
||
retryPath string // la base lue (LOKI_HOME change d'un test à l'autre)
|
||
retryChecked time.Time
|
||
retryMtime time.Time
|
||
retrySize int64
|
||
)
|
||
|
||
// retryStateBust : appelé par cacheBust pour toute écriture du bucket d'état.
|
||
func retryStateBust() {
|
||
retryMu.Lock()
|
||
retryStale = true
|
||
retryMu.Unlock()
|
||
}
|
||
|
||
func retryPatterns() []*regexp.Regexp {
|
||
res, _ := retryState()
|
||
return res
|
||
}
|
||
|
||
// retryState renvoie les motifs compilés et si ce sont ceux de la surcharge.
|
||
func retryState() ([]*regexp.Regexp, bool) {
|
||
retryMu.Lock()
|
||
defer retryMu.Unlock()
|
||
reload := !retryLoaded || retryStale || retryPath != dbPath()
|
||
if !reload && time.Since(retryChecked) >= time.Second {
|
||
retryChecked = time.Now()
|
||
if mtime, size := dbStamp(); size != retrySize || !mtime.Equal(retryMtime) {
|
||
reload = true
|
||
}
|
||
}
|
||
if reload {
|
||
raw := getStr(bkState, "retry_patterns")
|
||
retryMtime, retrySize = dbStamp()
|
||
retryChecked, retryStale, retryPath = time.Now(), false, dbPath()
|
||
if !retryLoaded || raw != retryRaw {
|
||
retryRes, retryCustom = compileRetryPatterns(raw)
|
||
retryRaw, retryLoaded = raw, true
|
||
}
|
||
}
|
||
return retryRes, retryCustom
|
||
}
|
||
|
||
// compileRetryPatterns compile la surcharge raw (tableau JSON de regex), ou
|
||
// les motifs embarqués quand elle est absente, illisible, ou sans un seul motif
|
||
// utilisable. Un motif vide est écarté : "(?mi)" correspond à TOUT, et chaque
|
||
// tour était relancé une fois (OpenFox 8e9b4869).
|
||
func compileRetryPatterns(raw string) ([]*regexp.Regexp, bool) {
|
||
// Surcharge optionnelle : clé d'état `retry_patterns`. Pas d'UI dédiée —
|
||
// c'est un réglage d'expert, posé via `loki config` ou l'API ; l'embarqué
|
||
// couvre les cas connus.
|
||
if raw != "" {
|
||
var over []string
|
||
if json.Unmarshal([]byte(raw), &over) == nil {
|
||
if out := compileRetryList(over); len(out) > 0 {
|
||
return out, true
|
||
}
|
||
}
|
||
}
|
||
return compileRetryList(defaultRetryPatterns), false
|
||
}
|
||
|
||
func compileRetryList(pats []string) []*regexp.Regexp {
|
||
var out []*regexp.Regexp
|
||
for _, p := range pats {
|
||
if strings.TrimSpace(p) == "" {
|
||
continue
|
||
}
|
||
if re, err := regexp.Compile("(?mi)" + p); err == nil {
|
||
out = append(out, re)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
||
// écrit en toutes lettres au lieu d'être émis par le protocole.
|
||
func textualToolCall(content string) bool {
|
||
return textualToolCallSnippet(content) != ""
|
||
}
|
||
|
||
// retryScanMargin / retryScanMaxBack : marge relue avant la fin du balayage
|
||
// précédent, et recul au-delà duquel on retombe sur le balayage complet.
|
||
const (
|
||
retryScanMargin = 512
|
||
retryScanMaxBack = 16 << 10
|
||
)
|
||
|
||
// textualToolCallFrom : textualToolCall pendant le flux, quand content[:scanned]
|
||
// a DÉJÀ été balayé sans rien trouver. Relire toute la réponse à chaque morceau
|
||
// coûtait jusqu'à 360 µs par jeton sur 80 Ko — du CPU volé au décodage quand
|
||
// des experts tournent sur le processeur. Une correspondance neuve finit
|
||
// forcément après scanned : on ne relit que la fin, à partir d'un vrai début de
|
||
// ligne (sinon `^` verrait un début de ligne au milieu d'une phrase et
|
||
// relancerait un tour parfaitement bon). Motifs de la surcharge : balayage
|
||
// complet, rien ne borne leur portée.
|
||
func textualToolCallFrom(content string, scanned int) bool {
|
||
if content == "" {
|
||
return false
|
||
}
|
||
res, custom := retryState()
|
||
start := 0
|
||
if !custom {
|
||
start = retryScanStart(content, scanned)
|
||
}
|
||
w := content[start:]
|
||
for _, re := range res {
|
||
if re.MatchString(w) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// retryScanStart : début de la fenêtre à relire. On part de scanned − marge,
|
||
// puis on recule tant qu'on est dans une suite que les motifs embarqués peuvent
|
||
// traverser sur toute sa longueur (\w+, \s*, `{"name":`, multioctet) : un
|
||
// `<function=` suivi de 600 lettres, ou un `{"name":` noyé dans les blancs,
|
||
// reste donc entier dans la fenêtre. Enfin, début de la ligne. Recul trop long
|
||
// ou ligne sans fin : 0, le balayage complet d'avant.
|
||
func retryScanStart(content string, scanned int) int {
|
||
if scanned > len(content) {
|
||
scanned = len(content)
|
||
}
|
||
p := scanned - retryScanMargin
|
||
if p <= 0 {
|
||
return 0
|
||
}
|
||
floor := scanned - retryScanMaxBack
|
||
for p > 0 && retryRunByte(content[p-1]) {
|
||
p--
|
||
if p < floor {
|
||
return 0
|
||
}
|
||
}
|
||
i := strings.LastIndexByte(content[:p], '\n')
|
||
if i < 0 || i+1 < floor {
|
||
return 0
|
||
}
|
||
return i + 1
|
||
}
|
||
|
||
func retryRunByte(c byte) bool {
|
||
switch {
|
||
case c >= 0x80, c == '_', c >= '0' && c <= '9', c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
|
||
return true
|
||
}
|
||
switch c {
|
||
case ' ', '\t', '\n', '\r', '\f', '"', ':', '{':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// textualToolCallSnippet renvoie l'extrait fautif (le motif reconnu et un peu
|
||
// de ce qui le suit), "" si aucun. Cité dans la consigne corrective : le
|
||
// modèle voit CE qu'il a mal écrit au lieu d'une remontrance abstraite.
|
||
func textualToolCallSnippet(content string) string {
|
||
if content == "" {
|
||
return ""
|
||
}
|
||
for _, re := range retryPatterns() {
|
||
if loc := re.FindStringIndex(content); loc != nil {
|
||
end := loc[1] + 80
|
||
if end > len(content) {
|
||
end = len(content)
|
||
}
|
||
for end < len(content) && !utf8.RuneStart(content[end]) {
|
||
end++
|
||
}
|
||
return strings.TrimSpace(content[loc[0]:end])
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// maxPatternRetries : relances « appel écrit en texte » consécutives. Le
|
||
// compteur repart à zéro après chaque appel d'outil réussi (OpenFox) : un long
|
||
// tour d'agent peut rater la syntaxe plusieurs fois, mais pas en boucle.
|
||
const maxPatternRetries = 3
|
||
|
||
// retryCorrective : le message réinjecté pour relancer le tour, avec l'extrait
|
||
// fautif.
|
||
func retryCorrective(snippet string) string {
|
||
return retryCorrectiveLead + snippet +
|
||
"\nTool calls must go through the tool-call protocol, never in the answer text. " +
|
||
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
||
}
|