diff --git a/internal/loki/code_retry.go b/internal/loki/code_retry.go index 9797a1a..a094a73 100644 --- a/internal/loki/code_retry.go +++ b/internal/loki/code_retry.go @@ -14,7 +14,9 @@ package loki import ( "encoding/json" "regexp" + "strings" "sync" + "unicode/utf8" ) // defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement @@ -29,6 +31,10 @@ var defaultRetryPatterns = []string{ `\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 @@ -67,18 +73,40 @@ func compileRetryPatterns() []*regexp.Regexp { // 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 { - if content == "" { - return false - } - for _, re := range retryPatterns() { - if re.MatchString(content) { - return true - } - } - return false + return textualToolCallSnippet(content) != "" } -// retryCorrective : le message réinjecté pour relancer le tour. -const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " + - "Tool 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." +// 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 "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n" + 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." +} diff --git a/internal/loki/code_retry_test.go b/internal/loki/code_retry_test.go index b2c02c0..b9bf535 100644 --- a/internal/loki/code_retry_test.go +++ b/internal/loki/code_retry_test.go @@ -1,6 +1,16 @@ package loki -import "testing" +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) // Les appels d'outils écrits en texte doivent être détectés… func TestAppelOutilTextuelDetecte(t *testing.T) { @@ -32,3 +42,60 @@ func TestReponseNormaleNonRelancee(t *testing.T) { } } } + +// Format XML de Qwen3-Coder resté en texte : détecté, et l'extrait est cité. +func TestAppelOutilQwenXMLDetecte(t *testing.T) { + s := "Je regarde.\n\nls -la\n" + snip := textualToolCallSnippet(s) + if snip == "" || !strings.HasPrefix(snip, "") { + t.Fatalf("extrait = %q", snip) + } + if textualToolCall("Un
et un en HTML.") { + t.Fatal("HTML ordinaire pris pour un appel d'outil") + } +} + +// Le faux appel est repéré PENDANT le flux : la génération est coupée (la suite +// n'arrive jamais à l'écran) et la relance cite l'extrait fautif. +func TestAppelTextuelCoupeLeFlux(t *testing.T) { + testHome(t) + var n int32 + var second string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + if atomic.AddInt32(&n, 1) == 1 { + _, _ = w.Write([]byte(sseChunk("Je lance "))) + w.(http.Flusher).Flush() + time.Sleep(300 * time.Millisecond) + _, _ = w.Write([]byte(sseChunk("FIN-INUTILE"))) + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n")) + return + } + var body struct { + Messages []Message `json:"messages"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + second = msgText(body.Messages[len(body.Messages)-1]) + _, _ = w.Write([]byte(sseChunk("ok"))) + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n")) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } + var content strings.Builder + if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "liste"}}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool { + content.WriteString(ev.Content) + return true + }); err != nil { + t.Fatal(err) + } + if strings.Contains(content.String(), "FIN-INUTILE") { + t.Fatal("le flux n'a pas été coupé sur l'appel écrit en texte") + } + if !strings.Contains(second, "") || !strings.Contains(second, "WRITTEN AS TEXT") { + t.Fatalf("relance sans l'extrait fautif : %q", second) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 598475e..c11381c 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -936,9 +936,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // par rendre la main plutôt que faire attendre. const maxNudges = 2 nudgeCount := 0 - // Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule - // relance par tour, comme le nudge. - patternRetried := false + // 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 // Reprises RÉSEAU consécutives (llm_retry_net.go). Remis à zéro dès qu'une // réponse arrive : une boucle d'outils longue retrouve son budget à chaque // itération réussie, un moteur durablement mort finit par rendre la main. @@ -1192,6 +1192,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps sc := bufio.NewScanner(resp.Body) sc.Buffer(make([]byte, 0, 64*1024), 8<<20) aborted := false + // patternHit : flux coupé sur un appel d'outil écrit en texte (voir plus bas). + patternHit := false for sc.Scan() { line := strings.TrimSpace(sc.Text()) if !strings.HasPrefix(line, "data:") { @@ -1352,6 +1354,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps aborted = true break } + // Appel d'outil écrit en texte, repéré PENDANT le flux (OpenFox) : + // on coupe tout de suite au lieu de laisser le modèle dérouler un + // faux appel — parfois un fichier entier — qui ne sera jamais + // exécuté. La relance corrective part juste après (voir fin de + // boucle). Testé seulement quand le morceau peut ouvrir un motif. + if len(tools) > 0 && !disableTools && patternRetries < maxPatternRetries && + strings.ContainsAny(ch.Delta.Content, "<`{[_.") && textualToolCall(assistantContent.String()) { + patternHit = true + break + } } else { // The prompt opened a block. Stream `content` LIVE as // the answer, holding back only a short tail that could be the @@ -1438,8 +1450,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps return extra, nil } // Coupure APRÈS le dernier chunk (finish_reason reçu) : la réponse est - // complète, seule la fermeture a raté. On la garde telle quelle. - if scanErr != nil && finishReason != "" { + // complète, seule la fermeture a raté. On la garde telle quelle. Flux + // coupé par NOUS sur un appel écrit en texte : pas une panne non plus. + if scanErr != nil && (finishReason != "" || patternHit) { scanErr = nil } // Flux coupé vers une API DISTANTE (Wi-Fi, VPN, proxy qui décroche) : on @@ -1529,6 +1542,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Début des résultats de CETTE étape, que le moteur n'a pas encore // comptés (voir le test de compactage en cours de tour, plus bas). stepStart := len(messages) + // Appel émis par le protocole : le budget de relances « appel écrit + // en texte » repart à neuf. + patternRetries = 0 // 2. Execute each tool locally and append a "tool" reply. for _, tc := range tcs { // Arrêt demandé : on n'enchaîne pas les outils restants. Sans ce @@ -1839,10 +1855,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // (code_retry.go). Le texte fautif reste affiché — le remplacer serait // mentir sur ce qui s'est passé — mais l'historique du modèle garde la // trace ET la correction, donc la vraie réponse suit immédiatement. - if !patternRetried && len(tools) > 0 && !disableTools && textualToolCall(assistantContent.String()) { - patternRetried = true + if snippet := textualToolCallSnippet(assistantContent.String()); snippet != "" && + patternRetries < maxPatternRetries && len(tools) > 0 && !disableTools { + patternRetries++ bad := Message{Role: "assistant", Content: assistantContent.String()} - fix := Message{Role: "user", Content: retryCorrective} + fix := Message{Role: "user", Content: retryCorrective(snippet)} messages = append(messages, bad, fix) extra = append(extra, bad, fix) continue