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`, // `{...}`, 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_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) : … ``, ``, } // 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 // ` 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." }