Merge pull request #34 from R0m1k3/fix/lot1-ajean-openfox

Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-19 11:22:20 +02:00
commit 6a45f19831
24 files changed
+1104 -112

No files matched your search

+56 -6
View File
@@ -242,6 +242,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
}
// compactRetryDue : une compaction en cours de tour peut-elle être tentée ?
// Après un essai qui n'a rien réduit (résumé raté, réduction sous 20 %),
// chaque étape suivante relançait un résumé complet voué au même échec : plus
// d'une heure de tentatives à vide, une par étape, sur une longue tâche (AJEAN
// 68659e73). compactRefusedSkip ne couvre que la discussion principale avec
// COMPACT_CONTINUATION ; ce garde-fou vaut pour tout tour, tâches et
// sous-agents compris. Jamais à 90 % de la fenêtre ou plus : là, essayer
// encore vaut mieux que déborder.
func compactRetryDue(retryAt, used int, msgs []Message) bool {
if retryAt <= 0 {
return true
}
est := compactUsedOrEstimate(used, msgs)
return est >= retryAt || est*10 >= ctxWindow()*9
}
// compactNextRetry : seuil du prochain essai après une compaction en cours de
// tour. 0 (aucun) si elle a réduit ; sinon le contexte d'aujourd'hui plus 5 %
// de la fenêtre.
func compactNextRetry(changed bool, used int, msgs []Message) int {
if changed {
return 0
}
est := compactUsedOrEstimate(used, msgs)
next := est + ctxWindow()/20
fmt.Fprintf(os.Stderr, "[compact] rien réduit en cours de tour : prochain essai à %d jetons (contexte à %d)\n", next, est)
return next
}
func compactUsedOrEstimate(used int, msgs []Message) int {
if used > 0 {
return used
}
return estimateTokens(msgs)
}
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
@@ -437,18 +473,24 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
var index []recallEntry
if caps.Agent && compactEnabled() {
names := torsoToolNames(torso)
var blocks []recallArchiveInput
var at []int // position dans le torse de chaque bloc
for i, m := range torso {
if !recallEligible(m) {
continue
}
content := msgText(m)
label := recallLabel(m.Role, names[m.ToolCallID], content)
id, aerr := archiveRecallBlock(label, m.Role, content)
if aerr != nil {
continue
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
at = append(at, i)
}
// Une seule transaction pour tous les blocs (AJEAN #130).
if entries, aerr := archiveRecallBlocks(blocks); aerr == nil {
for k, e := range entries {
archived[at[k]] = &recallEntry{id: e.id, label: e.label}
index = append(index, e)
}
archived[i] = &recallEntry{id: id, label: label}
index = append(index, recallEntry{id: id, label: label})
} else if len(blocks) > 0 {
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
}
}
@@ -528,6 +570,14 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
} else {
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
}
// La cause d'un résumé raté n'était écrite nulle part : on ne voyait que des
// compactions « changé=false » à répétition, sans savoir pourquoi (AJEAN
// 68659e73).
if err != nil {
fmt.Fprintf(os.Stderr, "[compact] résumé raté : %v\n", err)
} else if summaryLooksEmpty(summary) {
fmt.Fprintf(os.Stderr, "[compact] résumé vide ou dégénéré (%d car.)\n", len([]rune(strings.TrimSpace(summary))))
}
var mid []Message
if err != nil || summaryLooksEmpty(summary) {
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
+32
View File
@@ -0,0 +1,32 @@
package loki
import "testing"
// Après une compaction en cours de tour qui n'a rien réduit, l'étape suivante
// ne relance pas un résumé tant que le contexte n'a pas grossi de 5 % de la
// fenêtre — sauf à 90 % de la fenêtre ou plus.
func TestCompactRetryBackoff(t *testing.T) {
testHome(t)
if err := putStr(bkConfig, "CTX", "100000"); err != nil {
t.Fatal(err)
}
if !compactRetryDue(0, 70000, nil) {
t.Fatal("sans échec précédent, l'essai doit être permis")
}
next := compactNextRetry(false, 70000, nil)
if next != 75000 {
t.Fatalf("prochain essai à %d, attendu 75000", next)
}
if compactRetryDue(next, 72000, nil) {
t.Fatal("relancé alors que le contexte n'a presque pas grossi")
}
if !compactRetryDue(next, 75000, nil) {
t.Fatal("pas relancé alors que le contexte a grossi de 5 % de la fenêtre")
}
if !compactRetryDue(200000, 90000, nil) {
t.Fatal("à 90 % de la fenêtre, l'essai doit toujours être permis")
}
if compactNextRetry(true, 70000, nil) != 0 {
t.Fatal("une compaction réussie doit lever le garde-fou")
}
}
+65 -35
View File
@@ -78,58 +78,88 @@ func recallIDToSeq(id string) (uint64, bool) {
return n, true
}
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
// l'appelant se rabat sur le compactage classique (troncature sans id).
func archiveRecallBlock(label, role, content string) (string, error) {
// recallArchiveInput : un bloc à archiver (voir archiveRecallBlocks).
type recallArchiveInput struct {
Label string
Role string
Content string
}
// archiveRecallBlocks enregistre tous les blocs d'une compaction dans UNE
// transaction et renvoie leurs entrées, dans l'ordre des blocs fournis. Les ids
// sont alloués par NextSequence dans la transaction : monotones, sans
// collision, même si deux compactages se croisent. Une transaction par bloc
// payait une ouverture de la base et une synchronisation disque chacune, sous
// le verrou global de la base, en plein tour (AJEAN #130). Tout ou rien : en
// cas d'échec (base indisponible, ex. tests sans LOKI_HOME, mémoire
// verrouillée) aucun id n'est rendu, et l'appelant se rabat sur le compactage
// classique (troncature sans id).
func archiveRecallBlocks(blocks []recallArchiveInput) ([]recallEntry, error) {
if len(blocks) == 0 {
return nil, nil
}
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
encode, err := memEncoderNow()
if err != nil {
return "", err
return nil, err
}
var id string
now := time.Now().Unix()
var entries []recallEntry
err = withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
entries = entries[:0]
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
if err != nil {
return err
}
seq, err := b.NextSequence()
if err != nil {
return err
for _, in := range blocks {
seq, err := b.NextSequence()
if err != nil {
return err
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: in.Label,
Role: in.Role,
Content: in.Content,
Created: now,
}
raw, err := json.Marshal(blk)
if err != nil {
return err
}
// Un bloc archivé, c'est du verbatim de conversation : chiffré
// comme le reste quand la mémoire l'est (l'encodeur rend le clair
// inchangé si le chiffrement est inactif ; verrouillé,
// memEncoderNow a déjà refusé plus haut).
enc, err := encode(raw)
if err != nil {
return err
}
if err := b.Put(recallKey(seq), enc); err != nil {
return err
}
entries = append(entries, recallEntry{id: blk.ID, label: blk.Label})
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: label,
Role: role,
Content: content,
Created: time.Now().Unix(),
}
id = blk.ID
raw, err := json.Marshal(blk)
if err != nil {
return err
}
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
// refusé plus haut).
enc, err := encode(raw)
if err != nil {
return err
}
return b.Put(recallKey(seq), enc)
return nil
})
})
if err != nil {
return "", err
return nil, err
}
cacheBust(bkRecall)
return id, nil
return entries, nil
}
// archiveRecallBlock enregistre un seul bloc et renvoie son id ("r<seq>").
func archiveRecallBlock(label, role, content string) (string, error) {
e, err := archiveRecallBlocks([]recallArchiveInput{{Label: label, Role: role, Content: content}})
if err != nil {
return "", err
}
return e[0].id, nil
}
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
+44
View File
@@ -221,3 +221,47 @@ func TestCompactSummaryUserMsg(t *testing.T) {
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
}
}
// Les blocs d'une compaction s'écrivent en une seule transaction : ids dans
// l'ordre des blocs, croissants, chacun relisible mot pour mot.
func TestArchiveRecallBlocksBatch(t *testing.T) {
testHome(t)
if e, err := archiveRecallBlocks(nil); err != nil || e != nil {
t.Fatalf("lot vide : %v, %v", e, err)
}
in := []recallArchiveInput{
{Label: "un", Role: "tool", Content: bigBlock("PREMIER")},
{Label: "deux", Role: "user", Content: bigBlock("SECOND")},
{Label: "trois", Role: "assistant", Content: bigBlock("TROISIEME")},
}
entries, err := archiveRecallBlocks(in)
if err != nil {
t.Fatal(err)
}
if len(entries) != len(in) {
t.Fatalf("%d entrées pour %d blocs", len(entries), len(in))
}
var last uint64
for i, e := range entries {
seq, ok := recallIDToSeq(e.id)
if !ok || seq <= last {
t.Fatalf("id %q non croissant (précédent %d)", e.id, last)
}
last = seq
if e.label != in[i].Label {
t.Fatalf("entrée %d : libellé %q, attendu %q", i, e.label, in[i].Label)
}
blk, ok := recallGet(e.id)
if !ok || blk.Content != in[i].Content || blk.Role != in[i].Role {
t.Fatalf("bloc %s mal relu : %+v", e.id, blk)
}
}
// Un archivage isolé suit la même séquence.
id, err := archiveRecallBlock("quatre", "tool", bigBlock("QUATRE"))
if err != nil {
t.Fatal(err)
}
if seq, _ := recallIDToSeq(id); seq != last+1 {
t.Fatalf("id isolé %q, attendu r%d", id, last+1)
}
}
+7 -3
View File
@@ -41,11 +41,15 @@ func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
}
testHome(t)
start := time.Now()
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
if d := time.Since(start); d > 10*time.Second {
// Délai de 10 s, bien au-delà de WaitDelay (2 s) : c'est le détachement
// qui rend la main, pas le délai de la commande.
out := runShell(context.Background(), "sleep 30 & echo lance", 10)
if d := time.Since(start); d > 8*time.Second {
t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
}
if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
// La commande a fini (exit 0) : sa sortie est rendue, pas effacée par
// « WaitDelay expired » (AJEAN 7403a8a0).
if !strings.Contains(out, "exit: 0") || !strings.Contains(out, "lance") || !strings.Contains(out, "arrière-plan") {
t.Errorf("sortie inattendue : %q", out)
}
}
+11
View File
@@ -297,6 +297,14 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
// issue connue était de redémarrer loki-ui.
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
// ErrWaitDelay : la commande s'est terminée normalement, mais un process
// laissé en arrière-plan tenait encore les tubes. Ce qu'elle a écrit est
// déjà capturé : le rendre, au lieu de « [erreur: exec: WaitDelay expired
// before I/O complete] » qui l'effaçait (repris d'AJEAN 7403a8a0).
detached := errors.Is(err, exec.ErrWaitDelay)
if detached {
err = nil
}
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
// demandait un tail.
clean := func(s string) string { return tailOutput(stripANSI(s)) }
@@ -337,6 +345,9 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
if errOut != "" {
parts = append(parts, "stderr:\n"+errOut)
}
if detached {
parts = append(parts, "(un process lancé en arrière-plan tourne toujours ; sa sortie n'est plus suivie — pour un serveur, utilise bash_bg)")
}
return strings.Join(parts, "\n\n")
}
+75 -19
View File
@@ -16,6 +16,7 @@ import (
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
)
@@ -40,39 +41,94 @@ var defaultRetryPatterns = []string{
// 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 (
retryOnce sync.Once
retryRes []*regexp.Regexp
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
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
)
func retryPatterns() []*regexp.Regexp {
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
return retryRes
// retryStateBust : appelé par cacheBust pour toute écriture du bucket d'état.
func retryStateBust() {
retryMu.Lock()
retryStale = true
retryMu.Unlock()
}
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
pats := defaultRetryPatterns
custom := false
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
// regex. 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 := getStr(bkState, "retry_patterns"); raw != "" {
var over []string
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
pats, custom = over, true
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, custom
return out
}
// textualToolCall dit si le texte final du tour contient un appel d'outil
@@ -100,9 +156,9 @@ func textualToolCallFrom(content string, scanned int) bool {
if content == "" {
return false
}
res := retryPatterns()
res, custom := retryState()
start := 0
if !retryCustom {
if !custom {
start = retryScanStart(content, scanned)
}
w := content[start:]
+47
View File
@@ -0,0 +1,47 @@
package loki
import "testing"
// Un motif vide dans la surcharge correspondait à tout : chaque tour était
// relancé une fois. Il est écarté ; une surcharge sans motif utilisable retombe
// sur les motifs embarqués.
func TestCompileRetryPatternsSkipsEmpty(t *testing.T) {
res, custom := compileRetryPatterns(`["", " ", "<oups>"]`)
if !custom || len(res) != 1 {
t.Fatalf("custom=%v, %d motifs ; attendu la surcharge avec un seul motif", custom, len(res))
}
if res[0].MatchString("une réponse tout à fait normale") {
t.Fatal("un texte ordinaire déclenche une relance")
}
res, custom = compileRetryPatterns(`["", "([invalide"]`)
if custom || len(res) != len(defaultRetryPatterns) {
t.Fatalf("custom=%v, %d motifs ; attendu les motifs embarqués", custom, len(res))
}
if _, custom = compileRetryPatterns(`pas du json`); custom {
t.Fatal("surcharge illisible prise en compte")
}
}
// Un changement de retry_patterns agit sans redémarrer le process.
func TestRetryPatternsReloadOnChange(t *testing.T) {
testHome(t)
retryStateBust()
if _, custom := retryState(); custom {
t.Fatal("surcharge active sans clé")
}
if err := putStr(bkState, "retry_patterns", `["OUPS-OUTIL"]`); err != nil {
t.Fatal(err)
}
if !textualToolCall("bon, OUPS-OUTIL, voilà") {
t.Fatal("nouveau motif ignoré après l'écriture")
}
if err := putStr(bkState, "retry_patterns", ""); err != nil {
t.Fatal(err)
}
if _, custom := retryState(); custom {
t.Fatal("surcharge retirée mais toujours active")
}
if textualToolCall("bon, OUPS-OUTIL, voilà") {
t.Fatal("ancien motif encore appliqué")
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
package loki
import (
"context"
"encoding/json"
"errors"
"io"
@@ -857,10 +858,10 @@ func TestFacadeMCP(t *testing.T) {
if len(tools) != 1 || tools[0].Function.Name != "mcp__a__t" || mcpPromptLine(tools) == "" {
t.Fatalf("outils : %+v", tools)
}
if got := mcpCall("mcp__a__t", map[string]any{"x": 1}); got != "fait" {
if got := mcpCall(context.Background(), "mcp__a__t", map[string]any{"x": 1}); got != "fait" {
t.Fatalf("appel : %q", got)
}
if got := mcpCall("mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
if got := mcpCall(context.Background(), "mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
t.Fatalf("appel sans arguments : %q (%v)", got, calls)
}
MCPPrewarm()
@@ -872,7 +873,7 @@ func TestFacadeMCP(t *testing.T) {
}
// Façade injoignable : une erreur lisible, pas de panique.
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if got := mcpCall("mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
if got := mcpCall(context.Background(), "mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
t.Fatalf("façade injoignable : %q", got)
}
if tools := mcpTools(); tools != nil {
+7 -2
View File
@@ -1373,6 +1373,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
// Contexte estimé pour la requête suivante (en-tour), comparé au compte
// réel du moteur quand il arrive — journalisé s'il s'en écarte.
lastEst := 0
// Compaction en cours de tour qui n'a rien pu réduire : la retenter
// seulement une fois le contexte nettement plus gros (compactRetryDue).
compactRetryAt := 0
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
patternRetries := 0
@@ -2468,7 +2471,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
publishHistory()
}
}
if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used, messages) {
if compactNeeded(messages, used, peakGen) && compactRetryDue(compactRetryAt, used, messages) &&
!compactRefusedSkip(mainConv, used, messages) {
opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol,
disableTools || toolChoiceNone)
yes, no := true, false
@@ -2476,6 +2480,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
c, changed := compactMessagesNoted(ctx, messages, caps, opt)
cb(StreamEvent{Compacting: &no})
logCompact("en-tour", used, messages, c, changed)
compactRetryAt = compactNextRetry(changed, used, messages)
if changed {
lastEst = 0 // estimation d'avant compaction : plus rien à comparer
messages = c
@@ -2810,7 +2815,7 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
result, visionImg = toolCUScreenshot()
default:
if isMCPTool(name) {
result = mcpCall(name, args)
result = mcpCall(ctx, name, args)
} else {
result = "[erreur] outil inconnu: " + name
}
+85 -23
View File
@@ -25,6 +25,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -73,6 +74,14 @@ const (
lspMaxDiags = 10 // diagnostics montrés au modèle
)
// lspDiagQuiet : calme attendu après une publication avant de la croire
// définitive. gopls, rust-analyzer et pyright publient souvent en plusieurs
// fois pour une même version (liste vide d'abord, puis leur analyse, puis
// celle d'un outil externe) : rendre la PREMIÈRE donnait « propre » à une
// édition qui cassait la compilation. OpenFox attend 400 ms ; variable pour
// les tests.
var lspDiagQuiet = 350 * time.Millisecond
type lspServer struct {
lang string
root string
@@ -82,11 +91,17 @@ type lspServer struct {
nextID int
version map[string]int // uri → version de document
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
diagMu sync.Mutex
diags map[string][]lspDiagnostic
diagMu sync.Mutex
diags map[string][]lspDiagnostic
// diagVer : version de document annoncée par la dernière publication de
// l'uri, -1 quand le serveur n'en donne pas (champ facultatif du protocole).
diagVer map[string]int
diagCh chan string // uri fraîchement publié
lastUsed time.Time
dead bool
// dead : atomique, pas sous mu. writeMsg le pose sur une écriture ratée, et
// writeMsg tourne DANS check, qui tient déjà mu : le prendre là bloquait le
// serveur (et le tour) à vie. lspGet le lit aussi sans mu.
dead atomic.Bool
}
type lspDiagnostic struct {
@@ -214,7 +229,7 @@ func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
key := lang.ID + "\x00" + root
lspMu.Lock()
defer lspMu.Unlock()
if s := lspServers[key]; s != nil && !s.dead {
if s := lspServers[key]; s != nil && !s.dead.Load() {
return s, nil
}
s, err := lspStart(lang, root)
@@ -242,7 +257,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
}
s := &lspServer{
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
version: map[string]int{}, diags: map[string][]lspDiagnostic{},
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16), lastUsed: time.Now(),
}
go s.readLoop(stdout)
@@ -268,7 +283,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.dead {
if s.dead.Load() {
return nil, false
}
s.lastUsed = time.Now()
@@ -277,16 +292,29 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
return nil, false
}
uri := fileURI(absPath)
// Signaux en attente : tous PÉRIMÉS. Un serveur qui republie après le
// retour du check précédent laisse son uri dans le tampon ; lu ici tout de
// suite après didChange, il faisait rendre « aucun diagnostic » avant même
// que le serveur ait vu la nouvelle version.
for drained := false; !drained; {
select {
case <-s.diagCh:
default:
drained = true
}
}
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
s.diagMu.Lock()
delete(s.diags, uri)
delete(s.diagVer, uri)
s.diagMu.Unlock()
v, open := s.version[uri]
if !open {
s.version[uri] = 1
v = 1
s.version[uri] = v
s.notify("textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": s.lang, "version": 1, "text": string(content),
"uri": uri, "languageId": s.lang, "version": v, "text": string(content),
},
})
} else {
@@ -297,24 +325,56 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
"contentChanges": []map[string]any{{"text": string(content)}},
})
}
// Attend la publication pour CET uri, bornée.
deadline := time.After(lspDiagWait)
if s.dead.Load() {
return nil, false // écriture ratée : le serveur ne verra jamais cette version
}
// current : les diagnostics publiés pour CETTE version (ou sans version),
// pas ceux d'une version antérieure arrivés en retard.
current := func() ([]lspDiagnostic, bool) {
s.diagMu.Lock()
defer s.diagMu.Unlock()
d, ok := s.diags[uri]
if !ok {
return nil, false
}
if pv, has := s.diagVer[uri]; has && pv >= 0 && pv < v {
return nil, false
}
return d, true
}
// Attend la publication pour CET uri, puis un calme de lspDiagQuiet sans
// nouvelle publication ; le tout borné par lspDiagWait.
deadline := time.NewTimer(lspDiagWait)
defer deadline.Stop()
var quiet <-chan time.Time
var quietT *time.Timer
defer func() {
if quietT != nil {
quietT.Stop()
}
}()
for {
select {
case got := <-s.diagCh:
if got != uri {
continue
}
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
if _, ok := current(); !ok {
continue // publication d'une version antérieure
}
if quietT == nil {
quietT = time.NewTimer(lspDiagQuiet)
} else {
quietT.Reset(lspDiagQuiet)
}
quiet = quietT.C
case <-quiet:
d, _ := current()
return d, true
case <-deadline:
case <-deadline.C:
// Pas de publication à temps : certains serveurs ne publient rien
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
d, _ := current()
return d, true
}
}
@@ -353,6 +413,7 @@ func (s *lspServer) readLoop(r io.Reader) {
Method string `json:"method"`
Params struct {
URI string `json:"uri"`
Version *int `json:"version"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
} `json:"params"`
}
@@ -362,6 +423,11 @@ func (s *lspServer) readLoop(r io.Reader) {
if msg.Method == "textDocument/publishDiagnostics" {
s.diagMu.Lock()
s.diags[msg.Params.URI] = msg.Params.Diagnostics
ver := -1
if msg.Params.Version != nil {
ver = *msg.Params.Version
}
s.diagVer[msg.Params.URI] = ver
s.diagMu.Unlock()
select {
case s.diagCh <- msg.Params.URI:
@@ -377,7 +443,7 @@ func (s *lspServer) idleWatch() {
for range t.C {
s.mu.Lock()
idle := time.Since(s.lastUsed) > lspIdleTimeout
dead := s.dead
dead := s.dead.Load()
s.mu.Unlock()
if dead {
return
@@ -401,11 +467,7 @@ func (s *lspServer) shutdown() {
lspMu.Unlock()
}
func (s *lspServer) markDead() {
s.mu.Lock()
s.dead = true
s.mu.Unlock()
}
func (s *lspServer) markDead() { s.dead.Store(true) }
func (s *lspServer) send(method string, params any, isRequest bool) {
s.nextID++
+200
View File
@@ -0,0 +1,200 @@
package loki
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fakeLSP : un serveur LSP réduit à ce que check utilise. Pour chaque
// didOpen/didChange reçu, react(version, publish) décide des publications.
type fakeLSP struct {
s *lspServer
out *io.PipeWriter
}
func lspFrame(w io.Writer, v any) {
b, _ := json.Marshal(v)
fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(b), b)
}
func newFakeLSP(t *testing.T, react func(ver int, publish func(ver *int, diags []lspDiagnostic))) *fakeLSP {
t.Helper()
inR, inW := io.Pipe()
outR, outW := io.Pipe()
s := &lspServer{
lang: "go", root: t.TempDir(), stdin: inW,
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16), lastUsed: time.Now(),
}
go s.readLoop(outR)
go func() {
br := bufio.NewReader(inR)
for {
n := 0
for {
line, err := br.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
break
}
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
}
}
buf := make([]byte, n)
if _, err := io.ReadFull(br, buf); err != nil {
return
}
var msg struct {
Method string `json:"method"`
Params struct {
TextDocument struct {
URI string `json:"uri"`
Version int `json:"version"`
} `json:"textDocument"`
} `json:"params"`
}
if json.Unmarshal(buf, &msg) != nil {
continue
}
if msg.Method != "textDocument/didOpen" && msg.Method != "textDocument/didChange" {
continue
}
uri := msg.Params.TextDocument.URI
go react(msg.Params.TextDocument.Version, func(ver *int, diags []lspDiagnostic) {
params := map[string]any{"uri": uri, "diagnostics": diags}
if ver != nil {
params["version"] = *ver
}
lspFrame(outW, map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params})
})
}
}()
t.Cleanup(func() { inW.Close(); outW.Close() })
return &fakeLSP{s: s, out: outW}
}
func lspErr(msg string) []lspDiagnostic {
d := lspDiagnostic{Severity: 1, Message: msg}
return []lspDiagnostic{d}
}
func lspTempFile(t *testing.T) string {
t.Helper()
p := filepath.Join(t.TempDir(), "main.go")
if err := os.WriteFile(p, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func withLSPQuiet(t *testing.T, d time.Duration) {
old := lspDiagQuiet
lspDiagQuiet = d
t.Cleanup(func() { lspDiagQuiet = old })
}
// Un signal laissé dans le tampon par une republication tardive ne doit pas
// faire rendre « aucun diagnostic » à l'édition suivante.
func TestLSPCheckIgnoresStaleSignal(t *testing.T) {
withLSPQuiet(t, 80*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
time.Sleep(60 * time.Millisecond)
publish(&ver, lspErr("undefined: x"))
})
uri := fileURI(p)
f.s.version[uri] = 1 // document déjà ouvert
f.s.diagCh <- uri // republication tardive du check précédent
f.s.diags[uri] = nil // …dont la liste était vide
d, ok := f.s.check(p)
if !ok || len(d) != 1 || d[0].Message != "undefined: x" {
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur de la nouvelle version", d, ok)
}
}
// Une liste vide publiée d'abord (« clear ») puis l'analyse : c'est l'analyse
// qui compte.
func TestLSPCheckWaitsPastInitialClear(t *testing.T) {
withLSPQuiet(t, 150*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
publish(&ver, nil)
time.Sleep(60 * time.Millisecond)
publish(&ver, lspErr("missing return"))
})
d, ok := f.s.check(p)
if !ok || len(d) != 1 || d[0].Message != "missing return" {
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur publiée après la liste vide", d, ok)
}
}
// Une publication d'une version antérieure, arrivée en retard, est ignorée.
func TestLSPCheckIgnoresOlderVersion(t *testing.T) {
withLSPQuiet(t, 80*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
old := ver - 1
publish(&old, lspErr("ancienne erreur"))
time.Sleep(40 * time.Millisecond)
publish(&ver, nil)
})
f.s.version[fileURI(p)] = 3
d, ok := f.s.check(p)
if !ok || len(d) != 0 {
t.Fatalf("diagnostics = %+v, %v ; attendu aucun (seule la version 4 compte)", d, ok)
}
}
// Sans version dans la publication (champ facultatif), elle est prise.
func TestLSPCheckAcceptsUnversionedPublish(t *testing.T) {
withLSPQuiet(t, 50*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
publish(nil, lspErr("syntax error"))
})
start := time.Now()
d, ok := f.s.check(p)
if !ok || len(d) != 1 {
t.Fatalf("diagnostics = %+v, %v", d, ok)
}
if time.Since(start) > lspDiagWait {
t.Fatalf("check a attendu la borne au lieu du calme")
}
}
// Une écriture ratée vers le serveur ne bloque plus check (markDead prenait
// mu, déjà tenu par check).
func TestLSPCheckWriteFailureDoesNotDeadlock(t *testing.T) {
p := lspTempFile(t)
r, w := io.Pipe()
r.Close() // toute écriture échoue
s := &lspServer{
lang: "go", stdin: w,
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16),
}
done := make(chan struct{})
go func() {
s.check(p)
close(done)
}()
select {
case <-done:
case <-time.After(lspDiagWait + 2*time.Second):
t.Fatal("check bloqué après une écriture ratée")
}
if !s.dead.Load() {
t.Fatal("serveur pas marqué mort après une écriture ratée")
}
}
+96
View File
@@ -0,0 +1,96 @@
package loki
import (
"context"
"strings"
"testing"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// fakeMCPSlow branche un serveur MCP en mémoire dont l'outil « slow » ne rend
// la main qu'à l'annulation de sa requête (ou au bout de 30 s).
func fakeMCPSlow(t *testing.T) string {
t.Helper()
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "lent", Version: "1"}, nil)
srv.AddTool(&mcpsdk.Tool{Name: "slow", InputSchema: map[string]any{"type": "object"}},
func(ctx context.Context, _ *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(30 * time.Second):
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "fini"}}}, nil
}
})
st, ct := mcpsdk.NewInMemoryTransports()
ctx := context.Background()
ss, err := srv.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
cs, err := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "loki-test", Version: "1"}, nil).Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
ready := make(chan struct{})
close(ready)
name := mcpExposedName("lent", "slow")
mcpMgr.mu.Lock()
if mcpMgr.sessions == nil {
mcpMgr.sessions = map[string]*mcpSession{}
}
if mcpMgr.registry == nil {
mcpMgr.registry = map[string]mcpToolRef{}
}
mcpMgr.sessions["lent"] = &mcpSession{name: "lent", sess: cs, ready: ready}
mcpMgr.registry[name] = mcpToolRef{server: "lent", tool: "slow"}
mcpMgr.mu.Unlock()
t.Cleanup(func() {
mcpMgr.mu.Lock()
delete(mcpMgr.sessions, "lent")
delete(mcpMgr.registry, name)
mcpMgr.mu.Unlock()
cs.Close()
ss.Close()
})
return name
}
// Le bouton stop annule l'appel MCP en cours au lieu d'attendre ses 120 s.
func TestMCPCallHonorsTurnCancel(t *testing.T) {
testHome(t)
name := fakeMCPSlow(t)
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(150*time.Millisecond, cancel)
start := time.Now()
out := mcpCall(ctx, name, map[string]any{})
if d := time.Since(start); d > 5*time.Second {
t.Fatalf("l'appel a ignoré l'annulation du tour (%s)", d)
}
if !strings.Contains(out, "interrompu") {
t.Fatalf("sortie = %q ; attendu un appel interrompu", out)
}
}
func TestMCPCallTimeoutFor(t *testing.T) {
cases := []struct {
cfg MCPServerConfig
args map[string]any
want time.Duration
}{
{MCPServerConfig{}, nil, mcpCallTimeout},
{MCPServerConfig{CallTimeoutSec: 600}, nil, 10 * time.Minute},
{MCPServerConfig{}, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes
{MCPServerConfig{}, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes
{MCPServerConfig{}, map[string]any{"timeout": 5000.0}, mcpCallTimeout}, // 5 s : le défaut reste
{MCPServerConfig{}, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne
{MCPServerConfig{}, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond
{MCPServerConfig{CallTimeoutSec: 30}, nil, 30 * time.Second}, // plus court que le défaut
}
for i, c := range cases {
if got := mcpCallTimeoutFor(c.cfg, c.args); got != c.want {
t.Errorf("cas %d : %s, attendu %s", i, got, c.want)
}
}
}
+69 -7
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -374,7 +375,11 @@ func isMCPTool(name string) bool {
// mcpCall exécute un outil MCP à partir de son nom exposé et renvoie une chaîne
// résultat (texte aplati), cappée. Reconnecte une fois si la session est morte.
func mcpCall(name string, args map[string]any) string {
//
// parent est le contexte DU TOUR, comme pour runShell : l'appel naissait d'un
// context.Background(), donc le bouton stop n'arrêtait rien et le tour restait
// bloqué jusqu'au bout des 120 s (OpenFox 2ef584b8).
func mcpCall(parent context.Context, name string, args map[string]any) string {
if facadeMCP() { // le pool est celui de la façade (facade_mcp.go)
return facadeMCPCall(name, args)
}
@@ -384,35 +389,92 @@ func mcpCall(name string, args map[string]any) string {
if !ok {
return "[erreur] outil MCP inconnu ou serveur non connecté: " + name
}
servers, _ := LoadMCPConfig()
cfg, exists := servers[ref.server]
timeout := mcpCallTimeoutFor(cfg, args)
result, err := mcpCallOnce(ref, args)
result, err := mcpCallOnce(parent, ref, args, timeout)
if err != nil {
if parent.Err() != nil {
return "[appel MCP interrompu]"
}
if errors.Is(err, context.DeadlineExceeded) {
// Le serveur est vivant mais l'outil est lent : le relancer
// doublerait l'attente (et l'effet de l'outil).
return fmt.Sprintf("[erreur MCP] pas de réponse après %s (délai de l'appel ; un argument timeout plus grand l'allonge)", timeout.Round(time.Second))
}
// Session peut-être morte (process stdio tombé) : on l'invalide et on
// retente une fois avec une reconnexion fraîche.
servers, _ := LoadMCPConfig()
cfg, exists := servers[ref.server]
if exists && cfg.Enabled {
mcpInvalidate(ref.server)
mcpMgr.ensure(ref.server, cfg)
if result2, err2 := mcpCallOnce(ref, args); err2 == nil {
if result2, err2 := mcpCallOnce(parent, ref, args, timeout); err2 == nil {
return result2
} else {
err = err2
}
if parent.Err() != nil {
return "[appel MCP interrompu]"
}
}
return "[erreur MCP] " + err.Error()
}
return result
}
func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
// mcpCallTimeoutMax plafonne le délai d'un appel, quoi que demandent l'outil
// ou la config : au-delà, c'est un job d'arrière-plan, pas un appel d'outil.
const mcpCallTimeoutMax = 30 * time.Minute
// mcpCallTimeoutFor : délai d'un appel. Défaut mcpCallTimeout, remplacé par
// CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même
// (beaucoup en ont un : navigateur, build, requête) l'allonge de 30 s pour que
// l'outil ait le temps de rendre SA propre erreur de délai.
func mcpCallTimeoutFor(cfg MCPServerConfig, args map[string]any) time.Duration {
d := mcpCallTimeout
if cfg.CallTimeoutSec > 0 {
d = time.Duration(cfg.CallTimeoutSec) * time.Second
}
if t := mcpArgTimeout(args); t > 0 && t+30*time.Second > d {
d = t + 30*time.Second
}
return min(d, mcpCallTimeoutMax)
}
// mcpArgTimeout lit l'argument « timeout » d'un appel. Les serveurs MCP ne
// s'accordent pas sur l'unité : au-delà de 3600 on le lit en millisecondes
// (5000 = 5 s), en deçà en secondes (600 = 10 min).
func mcpArgTimeout(args map[string]any) time.Duration {
var v float64
switch x := args["timeout"].(type) {
case float64:
v = x
case int:
v = float64(x)
case int64:
v = float64(x)
case json.Number:
v, _ = x.Float64()
case string:
v, _ = strconv.ParseFloat(strings.TrimSpace(x), 64)
}
if v <= 0 {
return 0
}
if v > 3600 {
return time.Duration(v) * time.Millisecond
}
return time.Duration(v * float64(time.Second))
}
func mcpCallOnce(parent context.Context, ref mcpToolRef, args map[string]any, timeout time.Duration) (string, error) {
mcpMgr.mu.Lock()
s := mcpMgr.sessions[ref.server]
mcpMgr.mu.Unlock()
if s == nil || s.sess == nil {
return "", fmt.Errorf("serveur %s non connecté", ref.server)
}
ctx, cancel := context.WithTimeout(context.Background(), mcpCallTimeout)
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
res, err := s.sess.CallTool(ctx, &mcpsdk.CallToolParams{Name: ref.tool, Arguments: args})
if err != nil {
+2 -1
View File
@@ -1,6 +1,7 @@
package loki
import (
"context"
"os/exec"
"strings"
"sync"
@@ -83,7 +84,7 @@ func TestMCPEndToEnd(t *testing.T) {
t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools))
}
out := mcpCall(echoName, map[string]any{"message": "bonjour-loki"})
out := mcpCall(context.Background(), echoName, map[string]any{"message": "bonjour-loki"})
if !strings.Contains(out, "bonjour-loki") {
t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out)
}
+5
View File
@@ -50,6 +50,11 @@ type MCPServerConfig struct {
// non namespacé). Permet de garder un serveur connecté tout en masquant
// certains de ses outils. Vide = tous les outils exposés.
DisabledTools []string `json:"disabledTools,omitempty"`
// CallTimeoutSec : délai d'un appel d'outil de ce serveur, en secondes.
// 0 = défaut (mcpCallTimeout). Pour un serveur dont les outils sont longs
// par nature (build, scraping, requête lourde).
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
}
// ToolDisabled indique si un outil (nom réel) est masqué pour ce serveur.
+2 -2
View File
@@ -299,9 +299,9 @@ func TestPariteMCP(t *testing.T) {
results := map[string]string{}
for _, n := range []string{"echo", "multi", "media", "link", "struct", "fail", "empty", "big", "titled", "secret", "dot_tool", "nope"} {
name := mcpExposedName("alpha", n)
results[name] = mcpCall(name, callArgs[n])
results[name] = mcpCall(context.Background(), name, callArgs[n])
}
results["mcp__zeta__x"] = mcpCall("mcp__zeta__x", nil)
results["mcp__zeta__x"] = mcpCall(context.Background(), "mcp__zeta__x", nil)
fx["calls"] = results
fx["prompt_line_live"] = mcpPromptLine(mcpTools())
+4
View File
@@ -222,6 +222,10 @@ func cacheBust(bucket string) {
cacheMu.Lock()
delete(caches, bucket)
cacheMu.Unlock()
if bucket == bkState {
webKeyCacheBust()
retryStateBust()
}
}
// dbStamp renvoie la date et la taille du fichier de base — de quoi repérer
+10 -1
View File
@@ -340,15 +340,24 @@ func TestRetryScanStartWindows(t *testing.T) {
// Surcharge retry_patterns : ses motifs ne sont pas bornés, donc pas de fenêtre.
func TestTextualToolCallFromCustomFullScan(t *testing.T) {
retryPatterns()
retryMu.Lock()
saved := retryRes
savedCustom := retryCustom
defer func() { retryRes, retryCustom = saved, savedCustom }()
retryRes, retryCustom = []*regexp.Regexp{regexp.MustCompile(`(?mi)\Aoups`)}, true
retryChecked = time.Now() // pas de relecture pendant le test
retryMu.Unlock()
defer func() {
retryMu.Lock()
retryRes, retryCustom = saved, savedCustom
retryMu.Unlock()
}()
s := "oups.\n" + strings.Repeat("Une phrase, puis une autre.\n", 400)
if !textualToolCallFrom(s, len(s)-10) {
t.Fatal("motif ancré en tête manqué")
}
retryMu.Lock()
retryCustom = false
retryMu.Unlock()
if textualToolCallFrom(s, len(s)-10) {
t.Fatal("la fenêtre aurait dû le manquer (le test ne prouve rien sinon)")
}
+58 -2
View File
@@ -10,6 +10,8 @@ import (
"net/url"
"os"
"strings"
"sync"
"time"
)
// web_auth.go protège l'API de pilotage (loki web) quand elle est exposée sur
@@ -19,7 +21,8 @@ import (
// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
// protège llama-server / les complétions). On veut pouvoir donner à un client
// un accès aux complétions sans lui donner le droit de redémarrer la machine,
// et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un
// et inversement. Elle est relue à chaque requête,
// depuis un cache invalidé à chaque écriture (voir readWebKeyErr), pour qu'un
// changement de clé prenne effet sans redémarrer le serveur web.
// readWebKeyErr renvoie la clé de pilotage en distinguant « aucune clé » d'une
@@ -27,9 +30,62 @@ import (
// ouverte. Confondre les deux, c'est ouvrir l'API parce que la base était
// momentanément verrouillée par une commande CLI — une panne d'E/S qui désarme
// l'authentification. requireWebAuth refuse donc plutôt que d'ouvrir.
//
// requireWebAuth la consulte à CHAQUE requête : sans cache, chacune payait une
// ouverture complète de la base sous dbMu (vignettes chargées deux par deux,
// SSE, statut…), en concurrence avec l'écrivain des discussions (AJEAN #120).
// On garde la dernière lecture RÉUSSIE, invalidée comme cachedKV : écriture de
// ce process (cacheBust du bucket d'état), date/taille du fichier pour les
// autres process (la CLI), une seconde au plus. Une lecture ratée n'est jamais
// mise en cache : elle remonte, et l'API reste fermée.
func readWebKeyErr() (string, error) {
path := dbPath()
mtime, size := dbStamp()
c := &webKeyCache
c.Lock()
if c.ok && c.path == path && c.size == size && c.mtime.Equal(mtime) && time.Since(c.when) < cacheMaxAge {
k := c.key
c.Unlock()
return k, nil
}
gen := c.gen
c.Unlock()
b, err := getBytesErr(bkState, "web_key")
return string(b), err
if err != nil {
return "", err
}
// Date et taille relevées APRÈS la lecture : ouvrir la base peut à lui seul
// toucher le fichier (Windows), et un relevé d'avant ne correspondait alors
// jamais. Une écriture externe glissée entre la lecture et le relevé reste
// couverte par l'âge maximal.
mtime, size = dbStamp()
c.Lock()
// Une invalidation pendant la lecture : la valeur lue est peut-être déjà
// l'ancienne — ne pas la garder.
if c.gen == gen {
c.ok, c.path, c.key, c.when, c.mtime, c.size = true, path, string(b), time.Now(), mtime, size
}
c.Unlock()
return string(b), nil
}
var webKeyCache struct {
sync.Mutex
ok bool
gen uint64 // incrémenté à chaque invalidation
path string // la base lue (LOKI_HOME change d'un test à l'autre)
key string
when time.Time
mtime time.Time
size int64
}
// webKeyCacheBust : appelé par cacheBust pour toute écriture du bucket d'état.
func webKeyCacheBust() {
webKeyCache.Lock()
webKeyCache.ok = false
webKeyCache.gen++
webKeyCache.Unlock()
}
// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie (ou
+88
View File
@@ -0,0 +1,88 @@
package loki
import (
"testing"
"time"
bolt "go.etcd.io/bbolt"
)
// Une écriture de ce process est vue tout de suite : le cache ne retarde pas un
// changement de clé.
func TestWebKeyCacheInvalidatedByLocalWrite(t *testing.T) {
testHome(t)
if err := putStr(bkState, "web_key", "ancienne"); err != nil {
t.Fatal(err)
}
if k, err := readWebKeyErr(); err != nil || k != "ancienne" {
t.Fatalf("clé = %q, %v", k, err)
}
if err := putStr(bkState, "web_key", "nouvelle"); err != nil {
t.Fatal(err)
}
if k, err := readWebKeyErr(); err != nil || k != "nouvelle" {
t.Fatalf("clé après écriture = %q, %v ; attendu la nouvelle", k, err)
}
}
// Une écriture d'un AUTRE process (sans cacheBust ici) finit vue : au plus
// cacheMaxAge plus tard.
func TestWebKeyCacheSeesExternalWrite(t *testing.T) {
testHome(t)
if err := putStr(bkState, "web_key", "a"); err != nil {
t.Fatal(err)
}
if k, _ := readWebKeyErr(); k != "a" {
t.Fatalf("clé = %q", k)
}
// Écriture directe, comme le ferait la CLI : pas de cacheBust.
err := withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(bkState)).Put([]byte("web_key"), []byte("b"))
})
})
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(cacheMaxAge + 500*time.Millisecond)
for {
if k, _ := readWebKeyErr(); k == "b" {
return
}
if time.Now().After(deadline) {
t.Fatal("écriture externe jamais vue")
}
time.Sleep(50 * time.Millisecond)
}
}
// Sans changement, les lectures suivantes n'ouvrent plus la base.
func TestWebKeyCacheServesRepeatedReads(t *testing.T) {
testHome(t)
if err := putStr(bkState, "web_key", "k"); err != nil {
t.Fatal(err)
}
if k, _ := readWebKeyErr(); k != "k" {
t.Fatalf("clé = %q", k)
}
// Base verrouillée par « un autre process » : une lecture qui l'ouvrirait
// attendrait la borne de 5 s puis échouerait.
dbMu.Lock()
type res struct {
k string
err error
}
ch := make(chan res, 1)
go func() { k, err := readWebKeyErr(); ch <- res{k, err} }()
select {
case r := <-ch:
dbMu.Unlock()
if r.err != nil || r.k != "k" {
t.Fatalf("clé = %q, %v", r.k, r.err)
}
case <-time.After(300 * time.Millisecond):
dbMu.Unlock()
<-ch
t.Fatal("lecture servie par la base, pas par le cache")
}
}
+20 -3
View File
@@ -922,11 +922,19 @@ impl RetryPatterns {
if over.is_empty() {
return None;
}
let pats = over
// Un motif vide (ou blanc) est écarté : `(?mi)` correspond à TOUT, et
// chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
// motif utilisable, les motifs embarqués, comme le Go
// (compileRetryPatterns).
let pats: Vec<Box<dyn RetryPattern>> = over
.iter()
.filter(|p| !p.trim().is_empty())
.filter_map(|p| compile_go_regex(&format!("(?mi){p}")))
.map(|r| Box::new(GoRegex(r)) as Box<dyn RetryPattern>)
.collect();
if pats.is_empty() {
return None;
}
Some(RetryPatterns::custom(pats))
}
@@ -2907,10 +2915,19 @@ mod tests {
/// en `(?mi)`, sémantique du Go (classes et limites de mot ASCII).
#[test]
fn surcharge_retry_patterns() {
for raw in ["", "null", "[]", "[\"a\", 1]", "\"x\"", "[\"a\"] x"] {
for raw in [
"",
"null",
"[]",
"[\"a\", 1]",
"\"x\"",
"[\"a\"] x",
"[\"\"]",
"[\" \", \"(\"]",
] {
assert!(RetryPatterns::from_override(raw).is_none(), "{raw}");
}
let p = RetryPatterns::from_override(r#"["^oups", "[", "\\bdo_\\w+\\("]"#).unwrap();
let p = RetryPatterns::from_override(r#"["^oups", "[", "", "\\bdo_\\w+\\("]"#).unwrap();
assert!(p.is_custom());
assert_eq!(p.len(), 2, "la regex illisible est sautée");
assert!(p.textual_tool_call("Bonjour\nOUPS !"));
+102
View File
@@ -25,6 +25,14 @@ pub struct ServerConfig {
pub enabled: bool,
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
pub disabled_tools: Vec<String>,
/// `CallTimeoutSec` : délai d'un appel d'outil de ce serveur, en
/// secondes ; 0 = défaut ([`CALL_TIMEOUT`]).
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
pub call_timeout_sec: i64,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
impl Default for ServerConfig {
@@ -37,6 +45,7 @@ impl Default for ServerConfig {
headers: BTreeMap::new(),
enabled: true,
disabled_tools: Vec::new(),
call_timeout_sec: 0,
}
}
}
@@ -78,6 +87,7 @@ impl<'de> Deserialize<'de> for ServerConfig {
Some(x) => x.as_bool().unwrap_or(false),
},
disabled_tools: list("disabledTools"),
call_timeout_sec: v.get("callTimeoutSec").and_then(Value::as_i64).unwrap_or(0),
})
}
}
@@ -282,6 +292,53 @@ pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
/// `mcpCallTimeout`.
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
/// config.
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du
/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s.
pub fn call_timeout_for(cfg: &ServerConfig, args: &Map<String, Value>) -> Duration {
let mut d = CALL_TIMEOUT;
if cfg.call_timeout_sec > 0 {
d = Duration::from_secs(cfg.call_timeout_sec as u64);
}
if let Some(t) = arg_timeout(args) {
let t = t + Duration::from_secs(30);
if t > d {
d = t;
}
}
d.min(CALL_TIMEOUT_MAX)
}
/// `mcpArgTimeout` : l'argument `timeout` d'un appel. Au-delà de 3600, lu en
/// millisecondes (5000 = 5 s) ; en deçà, en secondes (600 = 10 min).
fn arg_timeout(args: &Map<String, Value>) -> Option<Duration> {
let v = match args.get("timeout")? {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse::<f64>().ok()?,
_ => return None,
};
if !v.is_finite() || v <= 0.0 {
return None;
}
if v > 3600.0 {
// Plafonné bien avant tout débordement (CALL_TIMEOUT_MAX suit).
return Some(Duration::from_millis(v.min(1e12) as u64));
}
Some(Duration::from_secs_f64(v))
}
/// `mcpCall` pour un délai dépassé : le serveur est vivant mais l'outil est
/// lent, pas de nouvel essai (il doublerait l'attente, et l'effet de l'outil).
pub fn call_deadline_error(timeout: Duration) -> String {
let secs = (timeout.as_millis() + 500) / 1000;
format!(
"[erreur MCP] pas de réponse après {} (délai de l'appel ; un argument timeout plus grand l'allonge)",
crate::tools::shell::go_duration(Duration::from_secs(secs as u64))
)
}
/// Nom du lanceur (`filepath.Base`), en minuscules, sans `.exe` puis sans
/// `.cmd`.
@@ -463,6 +520,51 @@ fn resource_contents_json(v: &Value) -> String {
mod tests {
use super::*;
/// Les cas de `TestMCPCallTimeoutFor` côté Go.
#[test]
fn delai_d_appel() {
let cfg = |n: i64| ServerConfig {
call_timeout_sec: n,
..Default::default()
};
let args = |v: Value| {
let mut m = Map::new();
m.insert("timeout".into(), v);
m
};
let s = Duration::from_secs;
let none = Map::new();
assert_eq!(call_timeout_for(&cfg(0), &none), CALL_TIMEOUT);
assert_eq!(call_timeout_for(&cfg(600), &none), s(600));
assert_eq!(call_timeout_for(&cfg(0), &args(json!(300.0))), s(330));
assert_eq!(call_timeout_for(&cfg(0), &args(json!(200000.0))), s(230));
assert_eq!(
call_timeout_for(&cfg(0), &args(json!(5000.0))),
CALL_TIMEOUT
);
assert_eq!(call_timeout_for(&cfg(0), &args(json!("400"))), s(430));
assert_eq!(
call_timeout_for(&cfg(0), &args(json!(86400.0 * 1000.0))),
CALL_TIMEOUT_MAX
);
assert_eq!(call_timeout_for(&cfg(30), &none), s(30));
assert_eq!(
call_deadline_error(s(330)),
"[erreur MCP] pas de réponse après 5m30s (délai de l'appel ; un argument timeout plus grand l'allonge)"
);
// Le champ survit à un aller-retour JSON, absent quand il vaut 0.
let c: ServerConfig =
serde_json::from_value(json!({"command": "x", "callTimeoutSec": 45})).unwrap();
assert_eq!(c.call_timeout_sec, 45);
assert_eq!(serde_json::to_value(&c).unwrap()["callTimeoutSec"], 45);
assert!(
serde_json::to_value(cfg(0))
.unwrap()
.get("callTimeoutSec")
.is_none()
);
}
#[test]
fn noms() {
assert_eq!(sanitize("mon serveur!"), "mon_serveur");
+15 -5
View File
@@ -39,6 +39,9 @@ pub const INTERNAL_TOOLS: &str = "/internal/facade/v1/mcp/tools";
pub const INTERNAL_CALL: &str = "/internal/facade/v1/mcp/call";
pub const INTERNAL_PREWARM: &str = "/internal/facade/v1/mcp/prewarm";
/// Erreur d'un appel au délai dépassé (`context.DeadlineExceeded` du Go).
const CALL_DEADLINE: &str = "context deadline exceeded";
/// Issue d'une tentative de connexion.
#[derive(Default)]
struct SlotState {
@@ -251,15 +254,20 @@ impl Mcp {
return format!("[erreur] outil MCP inconnu ou serveur non connecté: {name}");
};
let cap = config::output_cap(config::ctx_window(&store.config().await));
let mut err = match self.call_once(&server, &tool, args, cap).await {
let servers = Self::load(store).await;
let timeout =
config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args);
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => e,
};
let servers = Self::load(store).await;
if err == CALL_DEADLINE {
return config::call_deadline_error(timeout);
}
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
self.invalidate(&server).await;
self.ensure(&server, cfg, version).await;
match self.call_once(&server, &tool, args, cap).await {
match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => err = e,
}
@@ -273,6 +281,7 @@ impl Mcp {
tool: &str,
args: &Map<String, Value>,
cap: usize,
timeout: std::time::Duration,
) -> Result<String, String> {
let slot = lock(&self.slots).get(server).cloned();
let sess = slot
@@ -281,9 +290,9 @@ impl Mcp {
.and_then(|st| st.sess.clone())
.filter(|s| !s.is_closed())
.ok_or_else(|| format!("serveur {server} non connecté"))?;
let res = tokio::time::timeout(config::CALL_TIMEOUT, sess.call_tool(tool, args))
let res = tokio::time::timeout(timeout, sess.call_tool(tool, args))
.await
.map_err(|_| "context deadline exceeded".to_string())??;
.map_err(|_| CALL_DEADLINE.to_string())??;
let out = config::cap_output(config::flatten_content(&res), cap);
if res.get("isError").and_then(Value::as_bool) == Some(true) {
return Ok(format!("[l'outil a renvoyé une erreur]\n{out}"));
@@ -570,6 +579,7 @@ pub async fn serve(
.collect(),
enabled: d.opt_bool("enabled").unwrap_or(true),
disabled_tools: Vec::new(),
call_timeout_sec: 0,
};
let name = d.str("name");
let res = mcp