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]++