Agent : trois filets de secours qui faisaient plus de mal que de bien

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:04:35 +02:00
1 parent e12f07426f
commit 97378da7af
3 files changed
+68 -2

No files matched your search

+17
View File
@@ -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)
}
@@ -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)
}
}
}
+23 -2
View File
@@ -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]++