From 8171899ac1e7246da0976f547c6719a8f76110e7 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Oct 2026 23:23:21 +0200 Subject: [PATCH] =?UTF-8?q?Contexte=20:=20fini=20le=20400=20=C2=AB=20d?= =?UTF-8?q?=C3=A9passe=20la=20fen=C3=AAtre=20=C2=BB=20qui=20bloquait=20la?= =?UTF-8?q?=20discussion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repris d'AJEAN 0.17.5. Sur une fenêtre de 65k, un tour qui lit plusieurs gros fichiers d'un coup passait de 60 % à plus de 130 % sans jamais compacter, et l'erreur remontait telle quelle. - Le test de compactage en cours de tour compte aussi les résultats d'outils de l'étape, que le moteur n'a pas encore vus. Sans usage côté serveur, l'estimation porte toujours sur tout l'historique. - Tout résultat d'outil est borné à 30 000 caractères dans la vue du modèle, avec une mention qui l'invite à cibler. L'interface garde le résultat complet (« voir plus »). - Dernier recours quand le moteur refuse encore le prompt et que le compactage n'a rien pu faire : shrinkToFit tronque les gros résultats d'outils puis retire les plus vieux échanges, vers 60 % de la fenêtre corrigés par la taille réelle lue dans l'erreur. Deux essais au plus. - Tâches planifiées : la note de tâche passe en tête du message utilisateur au lieu du système. Le préfixe reste celui du chat et le cache de prompt de llama-server sert aux deux (l'amont mesurait 12 s de recalcul pour un « salut » après une tâche). Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_compact.go | 86 +++++++++++++++++++++ internal/loki/chat_compact_overflow_test.go | 43 ++++++++++- internal/loki/llm_client.go | 33 +++++++- internal/loki/tasks_run.go | 23 +++--- 4 files changed, 170 insertions(+), 15 deletions(-) diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index e40f3a3..6e92195 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "regexp" "strconv" "strings" ) @@ -686,3 +687,88 @@ func contextOverflow(msg string, msgs []Message) bool { } return estimateTokens(msgs) >= int(float64(ctxWindow())*0.9) } + +// ---- dernier recours : la requête ne rentre toujours pas ------------------- +// Repris d'AJEAN 0.17.5. + +var reqTokensRe = regexp.MustCompile(`request \((\d+) tokens\)`) + +// overflowTokens extrait la taille réelle de la requête refusée par llama.cpp +// (« request (132291 tokens) exceeds… »), 0 si absente. +func overflowTokens(msg string) int { + if m := reqTokensRe.FindStringSubmatch(msg); m != nil { + n, _ := strconv.Atoi(m[1]) + return n + } + return 0 +} + +const shrinkTruncMarker = "\n[…tronqué : contexte plein. Relance l'outil de façon plus ciblée si tu as besoin de la suite]" + +// shrinkToFit est le filet quand le moteur a refusé le prompt ET que le +// compactage n'a rien pu faire (torse vide, ou réduction jugée trop faible : +// typiquement un tour qui a lu beaucoup de gros fichiers d'un coup, tout est +// dans la queue protégée). Avant, l'erreur 400 remontait telle quelle et la +// conversation restait bloquée. Ici on réduit pour de bon : +// 1. les gros résultats d'outils (du plus ancien au plus récent) sont tronqués ; +// 2. si ça ne suffit pas, on retire les plus vieux échanges, par tour entier +// (frontière `user`), en gardant les messages système de tête. +// +// La cible vise 60 % de la fenêtre, corrigée par l'écart entre estimation et +// taille réelle (actual, lue dans l'erreur) : l'estimation sous-compte (images…). +func shrinkToFit(msgs []Message, actual int) ([]Message, bool) { + est := estimateTokens(msgs) + if est <= 0 { + return msgs, false + } + ratio := 1.0 + if actual > est { + ratio = float64(actual) / float64(est) + } + target := int(float64(ctxWindow()) * 0.6 / ratio) + out := append([]Message(nil), msgs...) + for i := range out { + if estimateTokens(out) <= target { + break + } + if out[i].Role != "tool" { + continue + } + if r := []rune(msgText(out[i])); len(r) > 2000 { + out[i].Content = string(r[:1500]) + shrinkTruncMarker + } + } + head := 0 + for head < len(out) && out[head].Role == "system" { + head++ + } + for estimateTokens(out) > target { + next := -1 + for k := head + 1; k < len(out); k++ { + if out[k].Role == "user" { + next = k + break + } + } + if next < 0 { + break + } + out = append(out[:head:head], out[next:]...) + } + return out, estimateTokens(out) < est +} + +// toolResultMax borne TOUT résultat d'outil dans la vue du modèle (≈ 8k +// tokens). Le terminal et le web ont déjà leur propre plafond, plus bas ; +// celui-ci rattrape les autres (lecture de fichier, pages mémoire, MCP…) : un +// seul résultat géant suffisait à faire déborder une fenêtre de 65k d'un coup, +// sans que le compactage puisse rien y faire. L'UI, elle, garde le résultat +// complet (« voir plus », tool_results.go). +const toolResultMax = 30000 + +func capToolResult(s string) string { + if r := []rune(s); len(r) > toolResultMax { + return string(r[:toolResultMax]) + "\n[…tronqué : résultat trop long. Cible une partie plus précise si tu as besoin de la suite]" + } + return s +} diff --git a/internal/loki/chat_compact_overflow_test.go b/internal/loki/chat_compact_overflow_test.go index 4aebba9..90bafdf 100644 --- a/internal/loki/chat_compact_overflow_test.go +++ b/internal/loki/chat_compact_overflow_test.go @@ -1,6 +1,9 @@ package loki -import "testing" +import ( + "strings" + "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. @@ -26,3 +29,41 @@ func TestContextOverflow(t *testing.T) { } } } + +// Le dernier recours ramène sous la fenêtre même quand tout est dans un seul +// tour (AJEAN 0.17.5). +func TestShrinkToFit(t *testing.T) { + testHome(t) + big := strings.Repeat("x", 200000) + msgs := []Message{ + {Role: "system", Content: "ctx"}, + {Role: "user", Content: "vieux"}, + {Role: "assistant", Content: "ok"}, + {Role: "user", Content: "lis tout"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "a"}}}, + {Role: "tool", ToolCallID: "a", Content: big}, + {Role: "tool", ToolCallID: "b", Content: big}, + } + out, changed := shrinkToFit(msgs, 132291) + if !changed || out[0].Role != "system" { + t.Fatalf("pas réduit : %v", changed) + } + if est := estimateTokens(out); est > int(float64(ctxWindow())*0.6) { + t.Fatalf("encore trop gros : %d", est) + } + if overflowTokens(`{"message":"request (132291 tokens) exceeds"}`) != 132291 { + t.Fatal("taille réelle non lue") + } +} + +// Un résultat d'outil géant est borné pour le modèle, avec une mention qui dit +// au modèle de cibler plus précisément. +func TestCapToolResult(t *testing.T) { + if got := capToolResult("court"); got != "court" { + t.Fatalf("résultat court modifié : %q", got) + } + got := capToolResult(strings.Repeat("é", toolResultMax+500)) + if r := []rune(got); len(r) > toolResultMax+200 || !strings.Contains(got, "tronqué") { + t.Fatalf("résultat géant mal borné (%d runes)", len(r)) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 6bf6bd8..598475e 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -914,6 +914,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // dépassement de la fenêtre de contexte après de gros résultats d'outils), on // compacte l'historique en vol et on rejoue le tour — une seule fois. compactedRetry := false + // Réductions forcées (shrinkToFit) quand le compactage n'a pas suffi. + shrinkRetries := 0 // Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par // tour, comme les autres filets. effortRetried := false @@ -1092,6 +1094,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps continue } } + // Compactage impuissant (ou déjà tenté et encore trop long) : réduction + // forcée plutôt que de laisser remonter un 400 qui bloque la + // conversation (AJEAN 0.17.5). + if compactEnabled() && shrinkRetries < 2 && contextOverflow(msg, messages) { + if c, changed := shrinkToFit(messages, overflowTokens(msg)); changed { + shrinkRetries++ + logCompact("réduction", overflowTokens(msg), messages, c, changed) + messages = c + extra = nil + cb(StreamEvent{NewHistory: append([]Message(nil), messages...)}) + continue + } + } // Most common 500 here: llama.cpp couldn't parse a malformed tool call // the model emitted. Retry the turn once without tools so it answers // in plain text rather than leaving the chat dead. Seulement sur 500 : @@ -1511,6 +1526,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps } messages = append(messages, assistant) extra = append(extra, assistant) + // 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) // 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 @@ -1750,7 +1768,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps shot = capturedRelPath(result) } cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result)}) - toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} + // Plafond pour le MODÈLE seulement : l'UI vient de recevoir le + // résultat complet (aperçu + « voir plus »). + toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)} messages = append(messages, toolMsg) extra = append(extra, toolMsg) // Capture d'écran + vision active : on fait SUIVRE l'image elle-même @@ -1788,7 +1808,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // enchaînés), on partait à 60% et on finissait en dépassement — rattrapé au // mieux par le filet réactif sur 500, une seule fois. On re-teste donc ici, // avec le contexte RÉEL du dernier appel (usage.prompt_tokens + généré). - if used := stats.PromptTokensTotal + stats.GenTokens; compactWouldTrigger(messages, used) { + // + les résultats d'outils de CETTE étape : le moteur ne les a pas encore + // comptés. Sans eux, une étape qui lit plusieurs gros fichiers d'un coup + // passait de 60 % à plus de 130 % de la fenêtre sans jamais compacter. + // Serveur sans usage (base 0) : on laisse 0, compactWouldTrigger estime + // alors TOUT l'historique — étape comprise. + used := 0 + if base := stats.PromptTokensTotal + stats.GenTokens; base > 0 { + used = base + estimateTokens(messages[stepStart:]) + } + if compactWouldTrigger(messages, used) { yes, no := true, false cb(StreamEvent{Compacting: &yes}) c, changed := compactMessages(ctx, messages, caps) diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go index 999e6cc..8a780ae 100644 --- a/internal/loki/tasks_run.go +++ b/internal/loki/tasks_run.go @@ -68,26 +68,25 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom if temperature == 0 { temperature = 0.7 } + // La note de tâche (conscience du mode autonome + compte-rendu du passage + // précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : le + // préfixe système + outils reste ainsi identique à celui du chat, et le cache + // de prompt de llama-server sert aux deux. Une note dans le système faisait + // tout recalculer à la tâche, PUIS au message suivant de l'utilisateur (12 s + // mesurées en amont pour un simple « salut », AJEAN 0.17.5). + if note := taskContextNote(taskName, lastReport); note != "" { + prompt = note + "\n\n" + prompt + } msgs := []Message{{Role: "user", Content: prompt}} // Même préambule que la vraie génération : consigne personnelle + préambule // agent + briefing machine (via InjectSkills), pour que l'IA ait le même - // contexte et les mêmes outils qu'en chat. On préfixe le tout d'une note de - // contexte (conscience du mode autonome + mémoire du passage précédent), - // fusionnée dans UN SEUL message système en tête — comme l'exigent les - // gabarits stricts (normalizeSystemMessages le garantit de toute façon). + // contexte et les mêmes outils qu'en chat. // Contexte du projet de la tâche : le projet est déjà forcé par l'appelant // (setProjectOverride dans runTask), donc ces messages décrivent le bon // chantier — la tâche voit la mémoire et les trackers qu'elle vise. final := append(projectSystemMessages(), msgs...) - sys := readSysPrompt() - note := taskContextNote(taskName, lastReport) - switch { - case sys != "" && note != "": - final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, final...) - case sys != "": + if sys := readSysPrompt(); sys != "" { final = append([]Message{{Role: "system", Content: sys}}, final...) - case note != "": - final = append([]Message{{Role: "system", Content: note}}, final...) } var content strings.Builder