mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 14:08:11 +02:00
Agent : un appel d'outil écrit en texte coupe le flux et la relance le cite
Repris d'OpenFox (stream-pure, agent-loop, 2.0.15x). - Repéré PENDANT le flux (hors réflexion en ligne) : la génération est coupée tout de suite au lieu de laisser le modèle dérouler un faux appel — parfois un fichier entier — qui ne serait jamais exécuté. - La consigne corrective cite l'extrait fautif : le modèle voit ce qu'il a mal écrit. - Jusqu'à 3 relances consécutives au lieu d'une par tour ; le compteur repart à zéro après chaque appel émis par le protocole. Un long tour d'agent pouvait rater la syntaxe deux fois et finir sur un pseudo-appel. - Nouveaux motifs : le format XML de Qwen3-Coder (<function=…>, <parameter=…>) quand le gabarit du moteur ne l'a pas converti. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b754c0cb6
commit
ad87f2fc9f
3 files changed
+134
-22
No files matched your search
+41
-13
@@ -14,7 +14,9 @@ package loki
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
|
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
|
||||||
@@ -29,6 +31,10 @@ var defaultRetryPatterns = []string{
|
|||||||
`\bfunctions\.\w+\s*\(`,
|
`\bfunctions\.\w+\s*\(`,
|
||||||
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
|
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
|
||||||
`\[TOOL_REQUEST\]`,
|
`\[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
|
// 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
|
// 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.
|
// écrit en toutes lettres au lieu d'être émis par le protocole.
|
||||||
func textualToolCall(content string) bool {
|
func textualToolCall(content string) bool {
|
||||||
if content == "" {
|
return textualToolCallSnippet(content) != ""
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, re := range retryPatterns() {
|
|
||||||
if re.MatchString(content) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// retryCorrective : le message réinjecté pour relancer le tour.
|
// textualToolCallSnippet renvoie l'extrait fautif (le motif reconnu et un peu
|
||||||
const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " +
|
// de ce qui le suit), "" si aucun. Cité dans la consigne corrective : le
|
||||||
"Tool calls must go through the tool-call protocol, never in the answer text. " +
|
// modèle voit CE qu'il a mal écrit au lieu d'une remontrance abstraite.
|
||||||
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
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."
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
package loki
|
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…
|
// Les appels d'outils écrits en texte doivent être détectés…
|
||||||
func TestAppelOutilTextuelDetecte(t *testing.T) {
|
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<function=bash>\n<parameter=command>ls -la</parameter>\n</function>"
|
||||||
|
snip := textualToolCallSnippet(s)
|
||||||
|
if snip == "" || !strings.HasPrefix(snip, "<function=bash>") {
|
||||||
|
t.Fatalf("extrait = %q", snip)
|
||||||
|
}
|
||||||
|
if textualToolCall("Un <div class=\"x\"> et un <param name=\"a\"> 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 <tool_call>")))
|
||||||
|
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, "<tool_call>") || !strings.Contains(second, "WRITTEN AS TEXT") {
|
||||||
|
t.Fatalf("relance sans l'extrait fautif : %q", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -936,9 +936,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
|||||||
// par rendre la main plutôt que faire attendre.
|
// par rendre la main plutôt que faire attendre.
|
||||||
const maxNudges = 2
|
const maxNudges = 2
|
||||||
nudgeCount := 0
|
nudgeCount := 0
|
||||||
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
|
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
|
||||||
// relance par tour, comme le nudge.
|
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
|
||||||
patternRetried := false
|
patternRetries := 0
|
||||||
// Reprises RÉSEAU consécutives (llm_retry_net.go). Remis à zéro dès qu'une
|
// 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
|
// 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.
|
// 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 := bufio.NewScanner(resp.Body)
|
||||||
sc.Buffer(make([]byte, 0, 64*1024), 8<<20)
|
sc.Buffer(make([]byte, 0, 64*1024), 8<<20)
|
||||||
aborted := false
|
aborted := false
|
||||||
|
// patternHit : flux coupé sur un appel d'outil écrit en texte (voir plus bas).
|
||||||
|
patternHit := false
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
line := strings.TrimSpace(sc.Text())
|
line := strings.TrimSpace(sc.Text())
|
||||||
if !strings.HasPrefix(line, "data:") {
|
if !strings.HasPrefix(line, "data:") {
|
||||||
@@ -1352,6 +1354,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
|||||||
aborted = true
|
aborted = true
|
||||||
break
|
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 {
|
} else {
|
||||||
// The prompt opened a <think> block. Stream `content` LIVE as
|
// The prompt opened a <think> block. Stream `content` LIVE as
|
||||||
// the answer, holding back only a short tail that could be the
|
// 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
|
return extra, nil
|
||||||
}
|
}
|
||||||
// Coupure APRÈS le dernier chunk (finish_reason reçu) : la réponse est
|
// 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.
|
// complète, seule la fermeture a raté. On la garde telle quelle. Flux
|
||||||
if scanErr != nil && finishReason != "" {
|
// coupé par NOUS sur un appel écrit en texte : pas une panne non plus.
|
||||||
|
if scanErr != nil && (finishReason != "" || patternHit) {
|
||||||
scanErr = nil
|
scanErr = nil
|
||||||
}
|
}
|
||||||
// Flux coupé vers une API DISTANTE (Wi-Fi, VPN, proxy qui décroche) : on
|
// 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
|
// 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).
|
// comptés (voir le test de compactage en cours de tour, plus bas).
|
||||||
stepStart := len(messages)
|
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.
|
// 2. Execute each tool locally and append a "tool" reply.
|
||||||
for _, tc := range tcs {
|
for _, tc := range tcs {
|
||||||
// Arrêt demandé : on n'enchaîne pas les outils restants. Sans ce
|
// 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
|
// (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
|
// 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.
|
// trace ET la correction, donc la vraie réponse suit immédiatement.
|
||||||
if !patternRetried && len(tools) > 0 && !disableTools && textualToolCall(assistantContent.String()) {
|
if snippet := textualToolCallSnippet(assistantContent.String()); snippet != "" &&
|
||||||
patternRetried = true
|
patternRetries < maxPatternRetries && len(tools) > 0 && !disableTools {
|
||||||
|
patternRetries++
|
||||||
bad := Message{Role: "assistant", Content: assistantContent.String()}
|
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)
|
messages = append(messages, bad, fix)
|
||||||
extra = append(extra, bad, fix)
|
extra = append(extra, bad, fix)
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in new issue
Block a user