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