v0.7.7 : la recherche internet ne tourne plus en rond

Le compactage du contexte detruisait ce dont l'IA avait besoin pour continuer
une recherche, ce qui la faisait boucler ou repartir de zero.

- Compactage : le resume est desormais fabrique a partir du torse ORIGINAL. On
  degraissait les resultats d'outils (page web -> marqueur) AVANT de resumer :
  le resumeur ne voyait plus aucune information trouvee, donc le resume n'en
  contenait aucune et l'IA relancait les memes recherches sans fin. Les
  resultats sont seulement raccourcis (1200 car.) pour le resumeur ; le torse
  degraisse ne sert plus que de repli si le resume echoue.
- Compactage : la demande EN COURS est preservee telle quelle. Pendant une
  boucle d'outils il n'y a aucun message user recent, elle tombait donc dans le
  torse et se dissolvait dans le resume, tandis que le TOUT PREMIER message de
  la conversation restait epingle en tete : l'IA repondait a la question du
  debut et abandonnait la recherche.
- Compactage : prompt du resumeur refait (findings, sources deja consultees,
  etat d'avancement) et plafond du resume porte de 1500 a 2200 caracteres.
- Appels repetes : l'avertissement passe EN TETE du resultat et, des la 2e
  redemande identique, le contenu n'est plus renvoye. Colle a la fin d'un
  resultat de plusieurs milliers de caracteres, il passait inapercu.
- Marqueur d'effacement du compactage : dit explicitement de ne PAS rappeler
  l'outil (l'ancien texte se lisait comme une invitation a re-telecharger).
- Outils web : sortie bornee a 8000 caracteres comme le shell. Un
  web_read(limit=500) pouvait injecter 25000 caracteres d'un coup et declencher
  les compactages en cascade.
- Bulles d'outils : la borne d'affichage passe de 4000 a 12000 caracteres, donc
  l'etiquette « ~N tok » cesse d'afficher toujours ~1004 (la valeur du plafond)
  et donne la vraie taille. Le chemin « appel identique non rejoue » envoyait
  lui le resultat brut sans troncature.
This commit is contained in:
nathaninline committed 2026-08-04 22:28:19 +02:00
1 parent aa92d2ab49
commit 9dedba0e47
10 files changed
+268 -52

No files matched your search

+79 -15
View File
@@ -36,11 +36,24 @@ const (
// retombe bas et met longtemps à re-déclencher (au lieu de compacter souvent).
compactTailFrac = 0.25
// Un résultat d'outil du torse plus long que ça est remplacé par un marqueur
// avant le résumé (dégraissage sans IA, gratuit).
// dans le torse DÉGRAISSÉ (repli si le résumé échoue).
compactToolPruneLen = 200
// Longueur à laquelle on RACCOURCIT (sans l'effacer) un résultat d'outil avant
// de le donner au résumeur : assez pour que les faits d'une page web y soient,
// assez court pour que dix pages tiennent dans la transcription.
compactToolSummaryLen = 1200
)
const compactPrunedMarker = "[Old tool result cleared to save context]"
// compactPrunedMarker remplace un vieux résultat d'outil dans le torse. Il dit
// EXPLICITEMENT de ne pas relancer l'outil : le texte précédent (« Old tool
// result cleared ») se lisait comme une invitation à re-télécharger la page, et
// le modèle repartait en boucle — page relue, contexte plein, nouveau compactage,
// résultat re-effacé, et ainsi de suite.
const compactPrunedMarker = "[Old tool result removed to save context. The important content is in the summary above — do NOT call this tool again to fetch it back.]"
// compactSummaryPrefix ouvre le message `user` synthétique qui porte le résumé.
// Sert aussi à le reconnaître pour ne pas le confondre avec une vraie demande.
const compactSummaryPrefix = "[CONTEXT COMPACTED]"
// compactEnabled indique si le compactage automatique du contexte est actif.
// Défaut : true. Seule une valeur off/false/0/no/non explicite (config.env
@@ -203,6 +216,8 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// 3. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
// marqueur. On travaille sur une copie pour ne pas muter l'historique amont.
// ⚠️ Ce torse dégraissé ne sert QUE de repli si le résumé échoue — surtout
// PAS d'entrée au résumeur, cf. juste en dessous.
pruned := make([]Message, len(torso))
for i, m := range torso {
pruned[i] = m
@@ -213,9 +228,29 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
}
}
// 4. Résumé du torse par le modèle local (un seul appel). En cas d'échec on
// garde le torse dégraissé plutôt que de perdre du contenu.
summary, err := summarizeTranscript(ctx, renderTranscript(pruned))
// 4. Résumé du torse par le modèle local (un seul appel).
//
// Le résumé se fait sur le torse ORIGINAL, pas sur le dégraissé. C'était LE
// bug de fond : on effaçait tous les résultats d'outils PUIS on demandait un
// résumé de ce qui restait. Le résumeur ne voyait donc que « Tool result:
// [Old tool result cleared] » à la place de chaque page web lue — le résumé
// ne pouvait contenir AUCUNE des informations trouvées, seulement la trace
// que des outils avaient tourné. À chaque compactage, l'IA repartait donc
// d'une recherche vide et recommençait à zéro : elle ne s'arrêtait jamais.
//
// Les résultats d'outils sont seulement RACCOURCIS (leur début, qui porte
// l'essentiel : titre, en-tête, premières lignes) pour que la transcription
// reste bornée. Les faits survivent, le volume reste maîtrisé.
forSummary := make([]Message, len(torso))
for i, m := range torso {
forSummary[i] = m
if m.Role == "tool" {
if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
}
}
}
summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
var mid []Message
if err != nil || strings.TrimSpace(summary) == "" {
mid = pruned
@@ -224,14 +259,37 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// message `system` au milieu : certains gabarits, ex. Qwen, exigent que le
// system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix).
mid = []Message{
{Role: "user", Content: "[CONTEXT COMPACTED] The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n" + summary},
{Role: "assistant", Content: "Understood, I'll continue from this context."},
{Role: "user", Content: compactSummaryPrefix + " The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n" + summary},
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, without redoing work that is already done."},
}
}
out := make([]Message, 0, head+len(mid)+len(msgs)-tailStart)
// La demande EN COURS ne doit JAMAIS être diluée dans le résumé. Pendant une
// longue boucle d'outils (recherche web : dix pages lues d'affilée), la queue
// n'est faite que d'appels d'outils : le message `user` qui a lancé la
// recherche tombe dans le torse, alors que le TOUT PREMIER message de la
// conversation, lui, reste épinglé en tête. Après compaction le modèle voyait
// donc, comme seule demande explicite, la question du DÉBUT de la conversation
// — et il y répondait en abandonnant la recherche en cours.
// On réinjecte donc textuellement la dernière vraie demande du torse, juste
// avant la queue (les résultats d'outils qu'elle a produits la suivent, comme
// dans l'historique d'origine). Le torse reste entièrement compactable.
var pending []Message
for i := len(torso) - 1; i >= 0; i-- {
if torso[i].Role != "user" {
continue
}
if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) {
continue // résumé d'une compaction précédente, pas une demande
}
pending = []Message{torso[i]}
break
}
out := make([]Message, 0, head+len(mid)+len(pending)+len(msgs)-tailStart)
out = append(out, msgs[:head]...)
out = append(out, mid...)
out = append(out, pending...)
out = append(out, msgs[tailStart:]...)
// Garantie de réduction : on n'accepte la compaction que si elle enlève au
@@ -293,12 +351,15 @@ type summarizeResp struct {
func summarizeTranscript(ctx context.Context, transcript string) (string, error) {
sys := `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
Summarize densely and faithfully, keeping ONLY the essentials:
- The user's goal(s) and constraints
- The user's CURRENT request, goal(s) and constraints
- FINDINGS: the concrete information already gathered — facts, figures, dates, names, URLs, file paths, values, config. This is the most important part: whatever is not here is lost and will have to be looked up again.
- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time
- Decisions made and established facts
- Actions/tools run and their important results (file paths, values, config)
- Tasks done, in progress, blocked; next steps
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Aim for 250 words MAX — this is a compression summary, not a report.
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Aim for 300 words MAX — this is a compression summary, not a report.
Write the summary in the SAME language as the conversation.`
payload := map[string]any{
@@ -349,9 +410,12 @@ Write the summary in the SAME language as the conversation.`
}
c = strings.TrimSpace(c)
// Garde-fou dur : même si le modèle ignore la consigne de longueur, on tronque
// (~1500 caractères ≈ 375 tokens) pour garantir une vraie compression.
if len(c) > 1500 {
c = strings.TrimSpace(c[:1500]) + " […]"
// pour garantir une vraie compression. 2200 caractères (≈ 550 tokens) et pas
// 1500 : le résumé doit désormais porter les FAITS déjà trouvés, pas seulement
// l'intention, sinon l'IA repart en recherche après chaque compactage. Coupé
// sur une frontière de rune (é, … ne doivent pas devenir des �).
if r := []rune(c); len(r) > 2200 {
c = strings.TrimSpace(string(r[:2200])) + " […]"
}
return c, nil
}
+62
View File
@@ -101,6 +101,68 @@ func TestCompactBoundsSplitsLongToolLoop(t *testing.T) {
}
}
// Régression : après compaction pendant une recherche web, la DEMANDE EN COURS
// doit encore figurer telle quelle dans l'historique. Sans ça, le seul message
// `user` restant était le tout premier de la conversation (épinglé en tête) et
// le modèle répondait à celui-là au lieu de continuer la recherche.
func TestCompactKeepsPendingRequest(t *testing.T) {
page := func(n int) Message {
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{
um("première question de la conversation"), am("ok"),
um("cherche les horaires du train pour Lyon"),
}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), page(i))
}
// summarizeTranscript échoue (pas de llama-server en test) → torse dégraissé,
// ce qui n'enlève rien à ce qu'on vérifie ici : la demande doit survivre.
out, _ := compactMessages(t.Context(), msgs, Caps{})
found := false
for _, m := range out {
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
found = true
}
}
if !found {
t.Fatal("la demande en cours a disparu de l'historique compacté")
}
}
// Régression : ce qu'on envoie au RÉSUMEUR doit encore contenir les résultats
// d'outils. On effaçait les résultats (dégraissage) AVANT de résumer : le
// résumeur ne voyait que des marqueurs, le résumé ne pouvait donc porter aucune
// information trouvée, et l'IA relançait la même recherche après chaque
// compactage — sans jamais s'arrêter.
func TestSummaryInputKeepsToolFindings(t *testing.T) {
fait := "le train de 14h12 part quai 3"
torso := []Message{
atc("web_read"),
tm(fait + strings.Repeat(" blabla de remplissage", 300)),
}
// Même transformation que compactMessages avant l'appel au résumeur.
forSummary := make([]Message, len(torso))
for i, m := range torso {
forSummary[i] = m
if m.Role == "tool" {
if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
}
}
}
tr := renderTranscript(forSummary)
if !strings.Contains(tr, fait) {
t.Fatal("le fait trouvé n'atteint pas le résumeur : le résumé sera vide d'information")
}
if strings.Contains(tr, compactPrunedMarker) {
t.Fatal("le résumeur reçoit des marqueurs d'effacement au lieu du contenu")
}
if len([]rune(tr)) > 4000 {
t.Fatalf("transcription non bornée (%d runes) : le résumeur va déborder", len([]rune(tr)))
}
}
func TestEstimateTokensGrows(t *testing.T) {
small := estimateTokens([]Message{um("court")})
big := estimateTokens([]Message{um("un message nettement plus long que le précédent pour dépasser")})
+17
View File
@@ -83,6 +83,23 @@ func webGrepTool() Tool {
// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
// webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme
// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) pour MCP. Sans ce
// plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000
// caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui
// déclenchait des compactages en cascade au milieu du raisonnement.
const webMaxOutput = 8000
// capWebOutput tronque en gardant le DÉBUT (contrairement au shell, où c'est la
// fin qui porte l'info) et dit au modèle comment lire la suite proprement.
func capWebOutput(s string) string {
if r := []rune(s); len(r) > webMaxOutput {
return string(r[:webMaxOutput]) +
"\n…[tronqué : réponse trop longue. Relis par tranches avec web_read(offset, limit) ou cible avec web_grep.]"
}
return s
}
func toolWebSearch(args map[string]any) string {
query, _ := args["query"].(string)
limit := 8
+57 -11
View File
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
@@ -306,6 +307,50 @@ type ToolUsedEvent struct {
Diff []DiffLine
}
// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI.
// Aligné sur le plus haut plafond côté modèle (mcpMaxOutput = 12000 ; shell et
// web = 8000) : le modèle et l'UI voient donc la même chose, et l'étiquette
// « ~N tok » de la bulle dit la VRAIE taille du résultat.
//
// Avant, cette borne était à 4000 : toute page web un peu longue s'affichait
// « ~1004 tok » — la valeur du plafond, pas celle de la page. Le compteur
// mentait, et il mentait toujours avec le même chiffre.
const shownDisplayMax = 12000
// shownResult prépare un résultat d'outil pour l'affichage.
func shownResult(s string) string {
if r := []rune(s); len(r) > shownDisplayMax {
return string(r[:shownDisplayMax]) + "\n…[tronqué]"
}
return s
}
// repeatedCallResult construit ce qu'on renvoie quand le modèle redemande un
// appel RIGOUREUSEMENT identique (même outil, mêmes arguments) dans le même tour.
// L'appel n'est jamais rejoué — on répond depuis le résultat mémorisé.
//
// Le plafond d'itérations et l'anti-boucle ont été retirés en v0.6.3 parce qu'ils
// coupaient des recherches légitimes ; il ne restait donc plus RIEN pour arrêter
// un modèle qui redemande dix fois la même page. Et le mécanisme se retournait
// contre lui-même : la note « déjà exécuté » était collée APRÈS le contenu, donc
// noyée en fin d'un résultat de plusieurs milliers de caractères — le modèle
// voyait le contenu, pas l'avertissement, et recommençait.
//
// D'où l'escalade : la note passe EN TÊTE, et à partir de la 2ᵉ redemande on ne
// renvoie plus la charge utile du tout. Le tour n'est pas coupé (le modèle garde
// la main), mais redemander la même chose ne rapporte plus rien — ni contenu, ni
// contexte consommé.
func repeatedCallResult(prev string, repeats int) string {
if repeats >= 2 {
return "[déjà fait] Cet appel exact a déjà été exécuté " + strconv.Itoa(repeats) +
" fois dans ce tour ; son résultat est plus haut dans la conversation. " +
"Ne le redemande plus : réponds avec ce que tu as, ou change d'approche " +
"(autre URL, autres arguments, web_grep pour cibler)."
}
return "[déjà fait] Appel identique déjà exécuté dans ce tour — non rejoué. " +
"Voici à nouveau son résultat ; ne le redemande pas une troisième fois.\n\n" + prev
}
// writeBodyKey returns the argument holding the text an écriture tool is about
// to commit — the part worth showing live in the bubble — or "" for tools that
// have no such body (bash, lectures, recherches).
@@ -466,6 +511,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas
// rejouer deux fois exactement la même écriture dans un même échange.
doneCalls := map[string]string{}
// Nombre de fois où le modèle a REDEMANDÉ un appel déjà exécuté. Sert à durcir
// la réponse progressivement (voir repeatedCallResult) : rendre le contenu une
// fois, puis refuser en le renvoyant vers ce qu'il a déjà.
repeatCount := map[string]int{}
// Garde-fou « pensé sans agir » : certains modèles à reasoning planifient un
// appel d'outil dans leur <think> puis émettent le token de fin SANS l'émettre
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
@@ -825,8 +874,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// introuvable », puisque le remplacement est déjà fait).
callKey := tc.Function.Name + "\x00" + tc.Function.Arguments
if prev, seen := doneCalls[callKey]; seen {
result = prev + "\n[note] appel identique déjà exécuté dans ce tour — non rejoué"
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: result, Done: true}})
repeatCount[callKey]++
result = repeatedCallResult(prev, repeatCount[callKey])
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true}})
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
messages = append(messages, toolMsg)
extra = append(extra, toolMsg)
@@ -904,13 +954,13 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
result = runShell(label, to)
case "web_search":
result = toolWebSearch(args)
result = capWebOutput(toolWebSearch(args))
case "web_open":
result = toolWebOpen(args)
result = capWebOutput(toolWebOpen(args))
case "web_read":
result = toolWebRead(args)
result = capWebOutput(toolWebRead(args))
case "web_grep":
result = toolWebGrep(args)
result = capWebOutput(toolWebGrep(args))
default:
if isMCPTool(tc.Function.Name) {
result = mcpCall(tc.Function.Name, args)
@@ -921,11 +971,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if !strings.HasPrefix(result, "[erreur]") {
doneCalls[callKey] = result
}
shown := result
if r := []rune(shown); len(r) > 4000 {
shown = string(r[:4000]) + "\n…[tronqué]"
}
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shown, Done: true, Diff: diff}})
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff}})
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
messages = append(messages, toolMsg)
extra = append(extra, toolMsg)
+31
View File
@@ -0,0 +1,31 @@
package ajean
import (
"strings"
"testing"
)
// Un appel d'outil redemandé à l'identique ne doit rendre son contenu qu'UNE
// fois, et l'avertissement doit être en TÊTE (collé après un résultat de
// plusieurs milliers de caractères, il passait inaperçu et le modèle rebouclait).
func TestRepeatedCallResultEscalates(t *testing.T) {
page := strings.Repeat("contenu de la page ", 300)
first := repeatedCallResult(page, 1)
if !strings.HasPrefix(first, "[déjà fait]") {
t.Fatalf("1re redemande : l'avertissement doit ouvrir le résultat, obtenu %.40q", first)
}
if !strings.Contains(first, page) {
t.Fatal("1re redemande : le contenu doit encore être rendu")
}
for _, n := range []int{2, 3, 7} {
again := repeatedCallResult(page, n)
if strings.Contains(again, page) {
t.Fatalf("redemande n°%d : le contenu ne doit plus être renvoyé", n)
}
if !strings.HasPrefix(again, "[déjà fait]") {
t.Fatalf("redemande n°%d : avertissement manquant", n)
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.7.6"
const Version = "0.7.7"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {