mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Contexte : fini le 400 « dépasse la fenêtre » qui bloquait la discussion
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
199d7f1e99
commit
8171899ac1
4 files changed
+170
-15
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+11
-12
@@ -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
|
||||
|
||||
Reference in new issue
Block a user