mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
aa92d2ab49
commit
9dedba0e47
10 files changed
+268
-52
No files matched your search
+18
-22
@@ -1,38 +1,34 @@
|
||||
Jean devient **AJEAN**, partout et plus seulement dans l'interface. Le dépôt, la commande, les dossiers et les services portent désormais le même nom.
|
||||
Cette version répare la recherche sur internet. Quand une recherche devenait longue, l'IA tournait en rond : elle rouvrait sans fin la même page, repartait de zéro, ou répondait soudain à une question posée bien plus tôt. Ce n'était pas le modèle, c'était le compactage du contexte qui effaçait exactement ce dont elle avait besoin.
|
||||
|
||||
Cette version a été construite autour d'une contrainte simple : **rien de ce qui fonctionne aujourd'hui ne doit cesser de fonctionner**. Il n'y a rien à réinstaller, rien à reconfigurer, et rien à déplacer soi-même.
|
||||
## L'IA ne repart plus de zéro au milieu d'une recherche
|
||||
|
||||
## La commande `jean` continue de marcher
|
||||
Quand la conversation devient trop longue pour la fenêtre de contexte, AJEAN la compacte : les vieux tours sont remplacés par un résumé. Ce résumé était fabriqué à partir d'un historique dont on avait d'abord effacé tous les résultats d'outils. Autrement dit, chaque page web lue avait déjà disparu quand le résumé était écrit. Le résumé ne pouvait donc contenir aucune des informations trouvées, seulement la trace que des recherches avaient eu lieu.
|
||||
|
||||
`ajean` devient la commande principale, mais `jean` reste installée à côté et fait exactement la même chose. Vos alias, vos scripts, vos tâches planifiées et vos raccourcis ne bougent pas.
|
||||
Résultat vu de l'extérieur : l'IA relançait les mêmes recherches en boucle, sans jamais aboutir.
|
||||
|
||||
De même, `JEAN_HOME` reste lu s'il a été défini à la main, au même titre que le nouveau `AJEAN_HOME`. Un emplacement choisi explicitement est une décision, pas un héritage à corriger.
|
||||
Le résumé est maintenant écrit à partir des vraies pages lues. On lui demande explicitement de conserver les informations récoltées (faits, chiffres, dates, adresses consultées) et de dire où en est le travail : ce qui est répondu, ce qui manque, la prochaine étape.
|
||||
|
||||
## Le dossier de données se renomme tout seul
|
||||
## La question en cours ne se perd plus
|
||||
|
||||
`%ProgramData%\jean` devient `%ProgramData%\ajean`, et `/etc/jean` devient `/etc/ajean` sous Linux et macOS. Aucune commande à lancer : la migration se fait pendant la mise à jour.
|
||||
Pendant une recherche, un tour peut enchaîner des dizaines d'appels d'outils sans le moindre message de votre part. Le compactage protégeait les tours récents et le tout premier message de la conversation, mais votre demande en cours, elle, se retrouvait au milieu, et disparaissait dans le résumé.
|
||||
|
||||
Le déplacement est instantané, même avec 200 Go de modèles — rien n'est recopié, le dossier est renommé sur place. Tout ce qui pointait dedans suit : les chemins de `config.env`, ceux de vos presets (dont le `--chat-template-file`), et jusqu'au raccourci du menu Démarrer, puisque le programme installé vit à l'intérieur de ce dossier.
|
||||
L'IA ne voyait alors plus qu'une seule question explicite : la première de la conversation. Et elle y répondait, en abandonnant la recherche en cours. Votre demande du moment est désormais toujours conservée telle quelle.
|
||||
|
||||
Votre clé d'accès distant est déplacée avec le reste : **aucun ré-appairage**, le tunnel se reconnecte seul.
|
||||
## Une même page n'est plus rouverte en boucle
|
||||
|
||||
Si vous aviez choisi vous-même l'emplacement, il n'est pas touché. Et si le renommage ne peut pas aboutir, **il ne se passe rien du tout** : l'ancien dossier reste en place et continue de servir, la tentative sera reprise plus tard. Rien n'est jamais copié ni supprimé.
|
||||
Un appel d'outil rigoureusement identique n'était déjà pas rejoué, mais l'avertissement était collé à la fin du contenu renvoyé, noyé après plusieurs milliers de caractères. L'IA voyait le contenu, pas l'avertissement, et recommençait.
|
||||
|
||||
## Le bouton de mise à jour redémarre AJEAN
|
||||
L'avertissement passe en tête, et à partir de la deuxième redemande le contenu n'est plus renvoyé du tout : redemander la même page ne rapporte plus rien et ne consomme plus de contexte. Le tour n'est jamais interrompu pour autant, une recherche qui enchaîne légitimement beaucoup d'appels reste possible.
|
||||
|
||||
Sous Windows, il fallait quitter puis relancer l'application à la main pour qu'une mise à jour prenne effet. C'est fait automatiquement, et la page se reconnecte d'elle-même.
|
||||
De même, le message qui remplace un vieux résultat d'outil disait seulement qu'il avait été effacé, ce qui se lisait comme une invitation à retélécharger la page. Il dit maintenant clairement de ne pas le faire.
|
||||
|
||||
Ce redémarrage est aussi ce qui permet au renommage d'aboutir : tant qu'AJEAN tourne, il tient son propre dossier. Le redémarrage ouvre le court instant où plus rien ne le retient.
|
||||
## Les pages web ne saturent plus le contexte
|
||||
|
||||
## Les serveurs Linux ne perdent pas la main
|
||||
La lecture d'une page n'avait aucune limite de taille, là où le terminal et les outils MCP en avaient une. Une seule lecture pouvait injecter 25 000 caractères d'un coup et remplir la fenêtre à elle seule, ce qui déclenchait les compactages en cascade décrits plus haut. La lecture est maintenant bornée, avec une indication de comment lire la suite par tranches.
|
||||
|
||||
Les unités systemd **ne sont pas renommées**. Un serveur qui tourne sous `jean.service` continue sous `jean.service` : vos commandes `systemctl`, vos règles `sudoers` et vos scripts de supervision restent valables. Seuls les chemins qu'elles contiennent sont corrigés, et une copie de l'unité d'origine est conservée à côté.
|
||||
## Le compteur de tokens des outils dit enfin la vérité
|
||||
|
||||
Une sauvegarde de la configuration, des presets et de la mémoire est déposée dans `/root` avant toute modification. Si une étape échoue, la machine est remise dans son état initial et les services relancés.
|
||||
|
||||
## Plus de dossier SKILLS
|
||||
|
||||
Les skills avaient été fondus dans la mémoire de l'IA, mais un dossier `SKILLS` vide continuait d'être créé. Il ne l'est plus. Si vous en avez encore un, son contenu est repris dans la mémoire et vous pouvez ensuite l'effacer.
|
||||
Sous chaque appel d'outil, l'étiquette « ~N tok » affichait le plus souvent la même valeur, autour de 1004, quelle que soit la page. Ce n'était pas la taille de la page mais celle du plafond appliqué à l'affichage. Ce plafond est aligné sur ce que voit réellement le modèle : les valeurs affichées varient donc désormais, et correspondent au coût réel.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
@@ -40,8 +36,8 @@ Les skills avaient été fondus dans la mémoire de l'IA, mais un dossier `SKILL
|
||||
ajean update
|
||||
```
|
||||
|
||||
Ou le bouton de l'interface, qui se charge désormais de tout.
|
||||
Ou le bouton de l'interface.
|
||||
|
||||
Les binaires sont publiés sous leurs deux noms, `ajean-*` et `jean-*`, le temps que les installations existantes basculent. Sous Windows, télécharger `ajean-windows-amd64.exe` depuis cette page et le lancer fait le même travail.
|
||||
Les binaires restent publiés sous leurs deux noms, `ajean-*` et `jean-*`, le temps que les installations existantes basculent.
|
||||
|
||||
L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas été vérifiée sur une vraie machine.
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,13 +3,13 @@
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 7,
|
||||
"Patch": 6,
|
||||
"Patch": 7,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 7,
|
||||
"Patch": 6,
|
||||
"Patch": 7,
|
||||
"Build": 0
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
|
||||
"OriginalFilename": "ajean.exe",
|
||||
"ProductName": "AJEAN",
|
||||
"ProductVersion": "0.7.6",
|
||||
"ProductVersion": "0.7.7",
|
||||
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user