From 97378da7aff0d5d7738586ec9561660b3563c6b9 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 28 Sep 2026 00:11:51 +0200 Subject: [PATCH] Agent : trois filets de secours qui faisaient plus de mal que de bien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repris d'AJEAN 0.15.5. COMPACTAGE DE SECOURS Sur n'importe quel refus du moteur — appel d'outil mal formé, modèle en cours de chargement, erreur de template — runChat résumait ~75 % de la conversation avant de rejouer, même quand elle tenait en trois messages. contextOverflow ne laisse passer que les vrais débordements : libellés de llama.cpp (« exceeds the available context size ») et des API OpenAI-compatibles (context_length_exceeded), ou, à défaut de libellé, une conversation déjà à 90 % de la fenêtre. ARGUMENTS D'OUTIL ILLISIBLES Un JSON tronqué était neutralisé en {} (indispensable : le template les re-parse à chaque requête) PUIS exécuté tel quel, d'où des erreurs trompeuses (« fichier manquant », « commande vide ») qui envoyaient le modèle sur une fausse piste. L'appel n'est plus exécuté ; le modèle reçoit une erreur qui dit exactement quoi renvoyer. Des arguments VIDES restent valides (outil sans paramètre). RELANCE SANS OUTILS La consigne imposait le français. Elle demande désormais la langue de l'utilisateur. Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_compact.go | 17 +++++++++++++ internal/loki/chat_compact_overflow_test.go | 28 +++++++++++++++++++++ internal/loki/llm_client.go | 25 ++++++++++++++++-- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 internal/loki/chat_compact_overflow_test.go diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index ef6ceef..2b96c01 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -649,3 +649,20 @@ Write the summary in the SAME language as the conversation.` } return c, nil } + +// contextOverflow : le refus du moteur (corps d'erreur msg) signale-t-il un +// débordement de la fenêtre de contexte ? Reconnaît les libellés de llama.cpp +// (« exceeds the available context size », exceed_context_size_error) et des API +// OpenAI-compatibles (context_length_exceeded, « maximum context length »). À +// défaut de libellé reconnu, on se fie à la taille estimée : une conversation +// déjà à 90 % de la fenêtre a toutes les chances d'être la cause. +func contextOverflow(msg string, msgs []Message) bool { + m := strings.ToLower(msg) + for _, k := range []string{"context size", "context_size", "context length", "context_length", + "context window", "n_ctx", "too many tokens", "prompt is too long", "exceeds the context"} { + if strings.Contains(m, k) { + return true + } + } + return estimateTokens(msgs) >= int(float64(ctxWindow())*0.9) +} diff --git a/internal/loki/chat_compact_overflow_test.go b/internal/loki/chat_compact_overflow_test.go new file mode 100644 index 0000000..4aebba9 --- /dev/null +++ b/internal/loki/chat_compact_overflow_test.go @@ -0,0 +1,28 @@ +package loki + +import "testing" + +// Le compactage de secours ne doit se déclencher que sur un vrai débordement de +// contexte, pas sur n'importe quel refus du moteur. +func TestContextOverflow(t *testing.T) { + small := []Message{{Role: "user", Content: "salut"}} + yes := []string{ + `{"error":{"code":400,"message":"request (70000 tokens) exceeds the available context size (65536 tokens), try increasing it","type":"exceed_context_size_error"}}`, + `{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}`, + } + no := []string{ + `{"error":{"code":500,"message":"Failed to parse tool call arguments as JSON","type":"server_error"}}`, + `{"error":{"code":503,"message":"Loading model","type":"unavailable_error"}}`, + `Unable to generate parser for this template`, + } + for _, m := range yes { + if !contextOverflow(m, small) { + t.Errorf("débordement non reconnu : %s", m) + } + } + for _, m := range no { + if contextOverflow(m, small) { + t.Errorf("faux débordement (déclencherait un compactage) : %s", m) + } + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index dfef05b..40e6796 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -981,7 +981,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Le prompt a peut-être dépassé la fenêtre de contexte : on tente une // compaction en vol et on rejoue le tour (une seule fois) avant tout le // reste. C'est le filet de secours à la Hermes. - if compactEnabled() && !compactedRetry { + // ⚠️ Seulement si l'erreur est VRAIMENT un débordement de contexte : avant, + // n'importe quel refus (appel d'outil mal formé, modèle en chargement, + // erreur de template…) résumait ~75 % de la conversation, même courte. + if compactEnabled() && !compactedRetry && contextOverflow(msg, messages) { if c, changed := compactMessages(ctx, messages, caps); changed { compactedRetry = true // ⚠️ Journaliser AVANT d'installer le résultat : l'ancien ordre @@ -1005,7 +1008,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Nudge the model to answer in plain text from what it already // gathered, so it doesn't immediately re-emit a tool call that // llama.cpp would again fail to parse. - messages = steerSystem(messages, "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues.") + messages = steerSystem(messages, "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered.") continue } // Dernier recours, APRÈS les filets sémantiques ci-dessus : un statut @@ -1273,6 +1276,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps } sort.Ints(idxs) tcs := make([]ToolCall, 0, len(idxs)) + // Appels dont les arguments sont du JSON CASSÉ (tronqué, mal formé) : on ne + // les exécute pas (voir plus bas). Des arguments vides restent valides : + // c'est la forme normale d'un outil sans paramètre. + badArgs := map[string]bool{} for i, k := range idxs { tc := *toolCalls[k] if tc.ID == "" { @@ -1287,6 +1294,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // en objet vide (l'appel a de toute façon déjà été exécuté). json.Valid("") // étant faux, ça couvre aussi le cas vide d'origine. if !json.Valid([]byte(tc.Function.Arguments)) { + if strings.TrimSpace(tc.Function.Arguments) != "" { + badArgs[tc.ID] = true + } tc.Function.Arguments = "{}" } tcs = append(tcs, tc) @@ -1326,6 +1336,17 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // rejoue pas. Les petits modèles réémettent volontiers deux fois la // même écriture ; la rejouer produisait une fausse erreur (« old // introuvable », puisque le remplacement est déjà fait). + // Arguments illisibles : l'exécuter avec des arguments vides donnait une + // erreur trompeuse (« fichier manquant », « commande vide »). On le dit + // tel quel au modèle, pour qu'il renvoie un appel complet. + if badArgs[tc.ID] { + result = "[erreur] arguments de l'appel illisibles (JSON invalide ou tronqué) : l'outil n'a PAS été exécuté. Renvoie l'appel avec des arguments JSON complets et valides." + cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: result, Done: true}}) + toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} + messages = append(messages, toolMsg) + extra = append(extra, toolMsg) + continue + } callKey := tc.Function.Name + "\x00" + tc.Function.Arguments if prev, seen := doneCalls[callKey]; seen && dedupableTool(tc.Function.Name) { repeatCount[callKey]++