diff --git a/README.md b/README.md index 85096ee..be8270a 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,15 @@ Ajoutées par ce fork : (`.oai.ajean.link`) est retirée de l'interface : elle exigeait un jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne pouvait donc qu'échouer. +- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une + recherche légitime est pire que la laisser durer — mais au-delà de 24 appels + sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui + demande de conclure. Le rappel revient tous les 24 appels, en durcissant le + ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière. + Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf + le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois + les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` — + `AGENT_BUDGET=off` le désactive complètement. - **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés dans le fil. - **Paramètres** : les réglages d'application (identité, apparence, accès diff --git a/internal/loki/llm_budget.go b/internal/loki/llm_budget.go new file mode 100644 index 0000000..af9a374 --- /dev/null +++ b/internal/loki/llm_budget.go @@ -0,0 +1,91 @@ +package loki + +// llm_budget.go — budget SOUPLE d'appels d'outils par tour. +// +// Le plafond dur (TOOL_LIMIT) et l'anti-boucle ont été retirés en v0.6.3 : ils +// coupaient des tours parfaitement légitimes, une recherche enchaînant sans +// problème des dizaines d'appels. Mais il ne restait plus RIEN entre « le +// modèle converge » et « le modèle tourne en rond pendant une heure » — vu en +// production : ~50 appels d'outil, 55 minutes, le modèle relisant cinq fois les +// mêmes fonctions pour re-dériver une conclusion qu'il avait déjà écrite. +// +// La déduplication d'appels identiques (repeatedCallResult) ne rattrape pas ce +// cas : sa clé est « nom + arguments bruts », et relire le même fichier par +// tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés +// différentes. Élargir cette clé à la CIBLE serait pire que le mal — deux +// tranches d'un même fichier renvoient un contenu différent, les confondre +// casserait toute lecture paginée légitime. +// +// D'où un budget qui ne coupe rien : au-delà d'un palier, on RAPPELLE au modèle +// combien d'appels il a déjà faits et on lui demande de conclure. Le tour reste +// entièrement sous son contrôle — c'est de la pression, pas une barrière. + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// agentBudgetDefault : nombre d'appels d'outils au-delà duquel un tour cesse +// d'être une recherche et commence à ressembler à du sur-place. Assez haut pour +// qu'une vraie enquête (lire dix fichiers, croiser trois pages web) passe sans +// jamais voir le rappel. +const agentBudgetDefault = 24 + +// agentBudget lit le palier dans config.env (AGENT_BUDGET). 0 — ou une valeur +// off/false/no/non — désactive complètement le mécanisme. +func agentBudget() int { + v := strings.ToLower(strings.TrimSpace(ReadConfig()["AGENT_BUDGET"])) + switch v { + case "": + return agentBudgetDefault + case "off", "false", "no", "non", "disable", "disabled": + return 0 + } + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + return n + } + return agentBudgetDefault +} + +// budgetNudge renvoie le rappel à injecter quand le tour vient de franchir un +// palier, ou "" s'il n'y a rien à dire. +// +// calls : appels d'outils exécutés depuis le début du tour +// budget : taille d'un palier (0 = mécanisme désactivé) +// sent : rappels déjà envoyés dans ce tour +// +// Les paliers sont à budget, 2×budget, 3×budget… : la pression revient donc +// régulièrement au lieu de s'éteindre après un avertissement ignoré. Le coût en +// contexte est négligeable (une phrase toutes les 24 requêtes) et le message est +// AJOUTÉ EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté +// llama-server. +// +// Le texte est en anglais comme le reste du protocole d'outils : c'est la langue +// dans laquelle les modèles suivent le mieux une consigne impérative, quelle que +// soit celle de la conversation. +func budgetNudge(calls, budget, sent int) string { + if budget <= 0 || calls < budget*(sent+1) { + return "" + } + n := fmt.Sprintf("%d tool calls", calls) + switch sent { + case 0: + return "[system] You have made " + n + " in this single turn. That is a lot: you almost certainly have enough to answer already. " + + "Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing." + case 1: + return "[system] " + n + " now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. " + + "Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further." + default: + return "[system] " + n + ". Stop. Write your final answer in this message. Do not call another tool." + } +} + +// logBudget trace UNE ligne par rappel sur la sortie d'erreur (donc dans +// `journalctl -u loki-ui`), comme logCompact. Sans ça, un tour qui part en +// vrille est invisible côté serveur : on ne voit qu'une génération qui dure. +func logBudget(calls, budget, sent int) { + fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n", + calls, budget, sent) +} diff --git a/internal/loki/llm_budget_test.go b/internal/loki/llm_budget_test.go new file mode 100644 index 0000000..17363db --- /dev/null +++ b/internal/loki/llm_budget_test.go @@ -0,0 +1,93 @@ +package loki + +import "testing" + +// Le budget ne se déclenche pas avant son palier, et il revient à chaque +// palier suivant : un modèle qui ignore le premier rappel doit en recevoir un +// second, plus ferme. +func TestBudgetNudgePaliers(t *testing.T) { + const budget = 24 + cases := []struct { + calls, sent int + want bool + }{ + {0, 0, false}, + {1, 0, false}, + {23, 0, false}, + {24, 0, true}, // 1er palier + {40, 1, false}, // rappel déjà envoyé, palier suivant pas atteint + {48, 1, true}, // 2e palier + {72, 2, true}, // 3e palier + {95, 3, false}, + {96, 3, true}, // 4e palier : la pression ne s'éteint pas + } + for _, c := range cases { + got := budgetNudge(c.calls, budget, c.sent) != "" + if got != c.want { + t.Errorf("budgetNudge(calls=%d, sent=%d) = %v, attendu %v", c.calls, c.sent, got, c.want) + } + } +} + +// Le message durcit à chaque rappel : trois textes distincts, tous porteurs du +// nombre d'appels (c'est LUI qui informe le modèle, pas le ton). +func TestBudgetNudgeEscalade(t *testing.T) { + const budget = 10 + seen := map[string]bool{} + for sent := 0; sent < 3; sent++ { + m := budgetNudge(budget*(sent+1), budget, sent) + if m == "" { + t.Fatalf("rappel n°%d vide", sent+1) + } + if seen[m] { + t.Errorf("rappel n°%d identique à un précédent : %q", sent+1, m) + } + seen[m] = true + } + // Au-delà, on réutilise le texte le plus ferme plutôt que d'en inventer un + // nouveau à chaque palier (à nombre d'appels égal, le message est le même). + if a, b := budgetNudge(50, budget, 3), budgetNudge(50, budget, 4); a != b { + t.Errorf("les rappels tardifs devraient partager le même texte :\n%q\n%q", a, b) + } +} + +// budget=0 (AGENT_BUDGET=off) : plus aucun rappel, quel que soit le nombre +// d'appels — le mécanisme doit pouvoir être totalement coupé. +func TestBudgetNudgeDesactive(t *testing.T) { + for _, calls := range []int{0, 24, 500, 10000} { + if m := budgetNudge(calls, 0, 0); m != "" { + t.Errorf("budget désactivé mais rappel émis à %d appels : %q", calls, m) + } + } + if m := budgetNudge(100, -5, 0); m != "" { + t.Errorf("budget négatif devrait être inerte, obtenu %q", m) + } +} + +func TestAgentBudgetConfig(t *testing.T) { + testHome(t) + set := func(v string) { + cfg := ReadConfig() + cfg["AGENT_BUDGET"] = v + if err := WriteConfig(cfg); err != nil { + t.Fatal(err) + } + } + for _, c := range []struct { + val string + want int + }{ + {"", agentBudgetDefault}, + {"40", 40}, + {"0", 0}, + {"off", 0}, + {"NON", 0}, + {"n'importe quoi", agentBudgetDefault}, // valeur illisible : on ne l'invente pas + {"-3", agentBudgetDefault}, + } { + set(c.val) + if got := agentBudget(); got != c.want { + t.Errorf("AGENT_BUDGET=%q → %d, attendu %d", c.val, got, c.want) + } + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 709e003..20b7e4b 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -599,11 +599,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge // explicite au lieu d'afficher « pas de réponse ». nudged := false - // Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours - // parfaitement légitimes (une recherche enchaîne facilement des dizaines - // d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule - // le contexte — c'est un choix assumé. + // Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond + // d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà + // d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui + // demande de conclure — de la pression, pas une barrière. Sans ça, un modèle + // qui tourne en rond n'avait rien en face de lui sauf le bouton stop. + toolRuns, budgetNudges, budget := 0, 0, agentBudget() for iter := 0; ; iter++ { + // Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté + // llama-server reste valide, seul le nouveau message est à traiter. + if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" { + budgetNudges++ + logBudget(toolRuns, budget, budgetNudges) + messages = append(messages, Message{Role: "user", Content: msg}) + } payload := map[string]any{ "model": "loki", "messages": messages, @@ -946,6 +955,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if ctx.Err() != nil { return extra, nil } + toolRuns++ // alimente le budget souple (voir budgetNudge) var args map[string]any _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) // Derive the human label (command / skill name) up front so we can diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 154beb5..abed389 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -5716,6 +5716,20 @@ function renderStats(el, s){ if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · ')); else paintStats(el, parts.join(' · ')); } +// --- Compteurs de bulle ----------------------------------------------------- +// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle +// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les +// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le +// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier +// token — exécution des outils, pages web et prefill compris. +// +// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait +// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur +// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite +// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert, +// aujourd'hui comme demain. +function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; } +function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; } // Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à // partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération, // donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un @@ -5840,11 +5854,11 @@ function handleDelta(d){ return; } if(d.reasoning_content){ killTyping('reasoning'); - if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); } + if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); } // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. - if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; } + if(d.replace){ T.fullReason=''; resetReasonStats(); } showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. @@ -5853,8 +5867,8 @@ function handleDelta(d){ return; } if(d.content){ removeTyping(); - if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; } - if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; } + if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } + if(d.replace){ T.fullContent=''; resetContentStats(); } T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent); if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1); labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs); diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index d6cc63a..d28bf5c 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -173,6 +173,20 @@ function renderStats(el, s){ if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · ')); else paintStats(el, parts.join(' · ')); } +// --- Compteurs de bulle ----------------------------------------------------- +// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle +// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les +// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le +// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier +// token — exécution des outils, pages web et prefill compris. +// +// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait +// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur +// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite +// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert, +// aujourd'hui comme demain. +function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; } +function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; } // Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à // partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération, // donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un @@ -297,11 +311,11 @@ function handleDelta(d){ return; } if(d.reasoning_content){ killTyping('reasoning'); - if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); } + if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); } // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. - if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; } + if(d.replace){ T.fullReason=''; resetReasonStats(); } showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. @@ -310,8 +324,8 @@ function handleDelta(d){ return; } if(d.content){ removeTyping(); - if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; } - if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; } + if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } + if(d.replace){ T.fullContent=''; resetContentStats(); } T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent); if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1); labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);