From 7c9aa04fd9133949a20524b2a3caf5a4da354244 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 08:26:04 +0200 Subject: [PATCH] =?UTF-8?q?Raisonnement=20:=20REASONING=5FECHO=20renvoie?= =?UTF-8?q?=20au=20moteur=20local=20la=20r=C3=A9flexion=20du=20mod=C3=A8le?= =?UTF-8?q?,=20en=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les gabarits Qwen3.5/3.6 rendent … pour chaque message assistant après la dernière question : c'est leur format entraîné. Loki ne gardait que le texte et les appels, donc à chaque étape d'une boucle d'outils le modèle relisait ses étapes précédentes avec des blocs vides, et le moteur recalculait le dernier message. Nouvelle clé REASONING_ECHO (off par défaut) : avec on, le raisonnement séparé par le serveur est gardé avec chaque message et renvoyé au même modèle. Sans la clé, rien n'est capturé ni envoyé : la requête est identique à l'octet près (testé). - Message.ReasoningContent (reasoning_content) + ReasoningModel, persistés ; seulement depuis reasoning_content du flux, jamais depuis le découpage « » fait chez nous (rendu en double) - un seul point de sortie (echoMessages) : étiquette toujours retirée, raisonnement retiré pour une API externe, un autre modèle, un message sans texte ni appel, ou si la sonde dit que le gabarit ne le rend jamais - comptage : estimateTokens/compactBounds comptent ce qui part et que le gabarit rend (passé seulement si la sonde dit qu'il le garde, ou l'ignore) ; ctxAfter ne retire plus un raisonnement renvoyé - débordement : relance d'abord sans raisonnement, avant toute compaction ; shrinkToFit retire le raisonnement le plus ancien avant tout le reste ; le torse compacté le perd comme le résumé - erreur de gabarit (raise_exception, thinking, Failed to parse messages) : relance une fois sans raisonnement avant tool_choice none / outils coupés ; retenu pour le modèle si la relance passe - runBuilderTurn applique enfin NewHistory comme generate (compaction perdue pendant une passe de correction) ; forwardStream affiche la bannière - sonde de gabarit : nouveau verdict renders_reasoning - REASONING_PRESERVE (lot 1) inchangée, son interaction documentée Co-Authored-By: Claude Opus 5.5 --- README.md | 12 + internal/loki/backend_serve_ckpt.go | 19 +- internal/loki/chat_cmd.go | 6 +- internal/loki/chat_compact.go | 33 +- internal/loki/chat_conversation.go | 6 +- internal/loki/chat_tplprobe.go | 31 +- internal/loki/chat_tplprobe_test.go | 27 +- internal/loki/code_verify.go | 37 +- internal/loki/llm_client.go | 113 ++++- internal/loki/llm_reasoning_echo.go | 268 ++++++++++ internal/loki/llm_reasoning_echo_test.go | 596 +++++++++++++++++++++++ internal/loki/sys_service.go | 10 +- 12 files changed, 1121 insertions(+), 37 deletions(-) create mode 100644 internal/loki/llm_reasoning_echo.go create mode 100644 internal/loki/llm_reasoning_echo_test.go diff --git a/README.md b/README.md index c495e31..d49cef5 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,18 @@ Ajoutées par ce fork : → maximale) et rejoue le message sans rien perdre de l'historique — une fois, puis la traduction est retenue pour ce modèle. La liste est grisée quand le raisonnement est coupé pour ce modèle. +- **Raisonnement renvoyé au modèle** (clé `REASONING_ECHO`, **off** par défaut, + `loki config set REASONING_ECHO on`) : le raisonnement que le moteur local a + séparé (`reasoning_content`) est gardé avec chaque message et renvoyé au même + modèle. Les gabarits Qwen3.5/3.6 relisent alors les étapes d'une boucle + d'outils avec leur réflexion — le format entraîné — au lieu de blocs vides, et + le moteur ne recalcule plus le dernier message. Avec `REASONING_PRESERVE=on` + (gabarits qui ont ce réglage, Qwen3.6), le préfixe reste stable d'un message + utilisateur à l'autre ; Qwen3.5 n'en a pas, le gain y reste interne au tour. + Contrepartie : plus de contexte par tour, donc compaction plus tôt. Jamais + vers une API externe ni vers un autre modèle ; un prompt trop long est d'abord + rejoué sans raisonnement, et un gabarit qui le refuse le suspend pour ce + modèle jusqu'au redémarrage. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_serve_ckpt.go b/internal/loki/backend_serve_ckpt.go index 3074bf5..ad4e276 100644 --- a/internal/loki/backend_serve_ckpt.go +++ b/internal/loki/backend_serve_ckpt.go @@ -289,12 +289,19 @@ func ckptArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not // // Le compromis, qui explique pourquoi Loki ne choisit pas : depuis b10763 le // moteur active preserve_reasoning par défaut, et le gabarit garde alors la -// réflexion de TOUS les tours passés. Mais Loki ne renvoie pas le raisonnement -// des tours passés : un gabarit qui ne conserve rien de lui-même (Qwen3.6) rend -// alors des blocs de réflexion vides, là où « off » rend l'historique des -// anciens moteurs. À l'inverse, un gabarit qui conserve déjà de lui-même -// (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est neutre -// pour tous les modèles : vide = défaut du moteur, inchangé. +// réflexion de TOUS les tours passés. Mais sans REASONING_ECHO, Loki ne renvoie +// pas le raisonnement des tours passés : un gabarit qui ne conserve rien de +// lui-même (Qwen3.6) rend alors des blocs de réflexion vides, là où « off » rend +// l'historique des anciens moteurs. À l'inverse, un gabarit qui conserve déjà de +// lui-même (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est +// neutre pour tous les modèles : vide = défaut du moteur, inchangé. +// +// Avec REASONING_ECHO=on (llm_reasoning_echo.go), les deux clés se complètent +// sans se recouvrir : l'écho fournit le raisonnement, cette clé décide si le +// gabarit le garde au-delà du tour en cours. « on » donne alors un préfixe +// stable d'un message utilisateur à l'autre, au prix de plus de contexte ; le +// comptage de Loki suit le verdict de la sonde de gabarit (preservesHistory), +// pas cette clé, car un gabarit sans le réglage (Qwen3.5) l'ignore. func reasoningPreserveArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) { v := strings.ToLower(strings.TrimSpace(cfg["REASONING_PRESERVE"])) if v == "" { diff --git a/internal/loki/chat_cmd.go b/internal/loki/chat_cmd.go index 92eef0f..4717daf 100644 --- a/internal/loki/chat_cmd.go +++ b/internal/loki/chat_cmd.go @@ -64,6 +64,8 @@ func cmdChat(args []string) error { full := strings.Builder{} inReason := false var stats *StatsEvent + // Raisonnement de la réponse finale (REASONING_ECHO), rangé avec elle. + var echo *ReasoningEcho // Print the assistant prefix once; reasoning is shown inline with a tag. fmt.Print(cyan("loki") + " > ") caps := globalCaps() @@ -90,6 +92,8 @@ func cmdChat(args []string) error { fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]")) case ev.Stats != nil: stats = ev.Stats + case ev.Echo != nil: + echo = ev.Echo case ev.DropReasoning: // Le tour a « pensé sans agir » : on relance. Impossible d'effacer le // texte déjà imprimé en terminal — on referme juste la ligne reasoning. @@ -155,7 +159,7 @@ func cmdChat(args []string) error { // answer, so next turn the model remembers it already did them instead // of re-invoking the same skill/command from scratch. msgs = append(msgs, extra...) - msgs = append(msgs, Message{Role: "assistant", Content: full.String()}) + msgs = append(msgs, withEcho(Message{Role: "assistant", Content: full.String()}, echo)) } } } diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index ce8662e..d4358a1 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -171,6 +171,9 @@ func msgText(m Message) string { // par token, plus un forfait par message pour le rôle et les délimiteurs). C'est // volontairement approximatif : le comptage EXACT vient de llama.cpp // (PromptTokensTotal) ; ici on veut juste décider quand compacter. +// Sans le raisonnement renvoyé (REASONING_ECHO) : celui-ci dépend de la +// position du message et de la politique d'envoi, il s'ajoute par echoTokens +// (estimateTokens, compactBounds). func msgTokens(m Message) int { n := 4 n += len(msgText(m)) / 4 @@ -180,12 +183,17 @@ func msgTokens(m Message) int { return n } -// estimateTokens estime la taille de l'historique en tokens. +// estimateTokens estime la taille de l'historique en tokens, raisonnement +// renvoyé compris quand il part réellement (echoTokens : rien sans +// REASONING_ECHO). func estimateTokens(msgs []Message) int { total := 0 for _, m := range msgs { total += msgTokens(m) } + for _, n := range echoTokens(msgs) { + total += n + } return total } @@ -331,8 +339,14 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) { } tailStart = len(msgs) acc := 0 + // Même compte que estimateTokens, qui fixe le budget : raisonnement renvoyé + // compris, sinon la queue avalait plus que sa part. + echo := echoTokens(msgs) for i := len(msgs) - 1; i >= head; i-- { acc += msgTokens(msgs[i]) + if echo != nil { + acc += echo[i] + } tailStart = i if acc >= tailBudget { break @@ -403,6 +417,10 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, pruned := make([]Message, len(torso)) for i, m := range torso { pruned[i] = m + // Le raisonnement renvoyé (REASONING_ECHO) d'un tour compacté ne repart + // pas : comme le résumé, qui ne l'a jamais vu (renderTranscript), le torse + // revient à ce que Loki envoyait sans la clé. + pruned[i].ReasoningContent, pruned[i].ReasoningModel = "", "" if e := archived[i]; e != nil { pruned[i].Content = recallMarker(e.id, e.label) continue @@ -888,6 +906,10 @@ const shrinkTruncMarker = "\n[…tronqué : contexte plein. Relance l'outil de f // 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 : +// 0. le raisonnement renvoyé (REASONING_ECHO), du plus ancien au plus récent : +// c'est ce que Loki n'envoyait pas sans la clé, à retirer avant tout le reste +// — y compris dans un seul long tour sans frontière `user`, que l'étape 2 +// ne sait pas couper ; // 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. @@ -905,6 +927,15 @@ func shrinkToFit(msgs []Message, actual int) ([]Message, bool) { } target := int(float64(ctxWindow()) * 0.6 / ratio) out := append([]Message(nil), msgs...) + for i := range out { + if out[i].ReasoningContent == "" && out[i].ReasoningModel == "" { + continue + } + if estimateTokens(out) <= target { + break + } + out[i].ReasoningContent, out[i].ReasoningModel = "", "" + } for i := range out { if estimateTokens(out) <= target { break diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 15b33ab..b5a942f 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -812,6 +812,8 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // Non-nil = elle remplace l'historique (elle contient déjà le tour en cours). var newBase []Message var content strings.Builder + // echo : raisonnement de la réponse finale (REASONING_ECHO), rangé avec elle. + var echo *ReasoningEcho // Le comptage EXACT du contexte vient de `usage.prompt_tokens` (option // include_usage). Tous les moteurs ne le renvoient pas — un llama-server // récent a cessé de le faire, et la jauge est restée bloquée à zéro sur des @@ -876,6 +878,8 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa base = base[1:] } newBase = append([]Message(nil), base...) + case ev.Echo != nil: + echo = ev.Echo case ev.Compacting != nil: // Compaction déclenchée pendant la boucle d'outils : même bannière que la // compaction de début de tour. @@ -925,7 +929,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa } c.Messages = append(c.Messages, extra...) if s := content.String(); strings.TrimSpace(s) != "" { - c.Messages = append(c.Messages, Message{Role: "assistant", Content: s}) + c.Messages = append(c.Messages, withEcho(Message{Role: "assistant", Content: s}, echo)) } // Rappel de loki resté sans réponse (stop, erreur) ou collé à un autre // user : retiré, sinon deux `user` d'affilée au tour suivant. diff --git a/internal/loki/chat_tplprobe.go b/internal/loki/chat_tplprobe.go index bee0a9b..805a47a 100644 --- a/internal/loki/chat_tplprobe.go +++ b/internal/loki/chat_tplprobe.go @@ -29,12 +29,15 @@ import ( // le moteur recalcule depuis l'endroit où les deux divergent, quoi que fasse // son cache. // -// PUREMENT DIAGNOSTIQUE. Rien ici ne touche à la construction d'un prompt, et -// Loki ne renvoie aujourd'hui JAMAIS de reasoning_content au modèle (Message n'a -// pas ce champ) : preservesHistory décrit le gabarit, pas ce que Loki fait. Une -// évolution qui s'appuierait sur ces réponses pour ajouter, retirer ou -// réordonner quoi que ce soit dans l'historique devra passer sa propre revue de -// fidélité, et traiter « unknown » comme « garder le comportement d'aujourd'hui ». +// Plus une troisième, déduite des mêmes rendus : rendersReasoning, le gabarit +// rend-il reasoning_content quelque part ? +// +// DIAGNOSTIQUE d'abord. Rien ici ne construit un prompt : preservesHistory +// décrit le gabarit, pas ce que Loki fait. Seul consommateur : REASONING_ECHO +// (llm_reasoning_echo.go, opt-in), qui s'abstient de renvoyer un raisonnement +// que le gabarit ne rend jamais, et ne compte celui d'avant la dernière +// question que si le gabarit le garde. Jamais pour AJOUTER quoi que ce soit : +// « unknown » y laisse la décision à la clé, avec ses filets. // // Règles de conduite, toutes là pour qu'une sonde ne coûte jamais rien au vrai // travail : @@ -69,6 +72,10 @@ const ( type tplProbeResult struct { PreservesHistory tplTri `json:"preserves_history"` PrefixStable tplTri `json:"prefix_stable"` + // RendersReasoning : reasoning_content apparaît-il dans un rendu, au moins + // juste après la dernière question ? « no » = le gabarit (ou le moteur) + // l'ignore toujours. + RendersReasoning tplTri `json:"renders_reasoning"` Build string `json:"build,omitempty"` Model string `json:"model,omitempty"` // nom du fichier, pas le chemin TemplateHash string `json:"template_hash,omitempty"` @@ -85,7 +92,7 @@ type tplProbeResult struct { } func tplUnknownResult(note string) tplProbeResult { - return tplProbeResult{PreservesHistory: tplUnknown, PrefixStable: tplUnknown, Note: note, At: time.Now()} + return tplProbeResult{PreservesHistory: tplUnknown, PrefixStable: tplUnknown, RendersReasoning: tplUnknown, Note: note, At: time.Now()} } // tplShape : la forme de la requête à reproduire. Les outils sont figés en JSON @@ -282,8 +289,8 @@ func tplProbeStore(key string, r tplProbeResult) { } func tplProbeLine(r tplProbeResult) string { - s := fmt.Sprintf("[tplprobe] build=%s modèle=%s gabarit=%s outils=%d historique_raisonnement=%s préfixe_stable=%s", - orDash(r.Build), orDash(r.Model), orDash(r.TemplateHash), r.Tools, r.PreservesHistory, r.PrefixStable) + s := fmt.Sprintf("[tplprobe] build=%s modèle=%s gabarit=%s outils=%d historique_raisonnement=%s préfixe_stable=%s raisonnement_rendu=%s", + orDash(r.Build), orDash(r.Model), orDash(r.TemplateHash), r.Tools, r.PreservesHistory, r.PrefixStable, r.RendersReasoning) if r.Note != "" { s += " (" + r.Note + ")" } @@ -505,9 +512,13 @@ func (p tplProber) probe(ctx context.Context, shape tplShape) tplProbeResult { default: r.PrefixStable = tplNo } - if err1 == nil && !strings.Contains(r1, tplMarkR1) && r.PreservesHistory == tplNo { + switch { + case r.PreservesHistory == tplYes || (err1 == nil && strings.Contains(r1, tplMarkR1)): + r.RendersReasoning = tplYes + case err1 == nil && strings.Contains(r1, tplMarkT1) && r.PreservesHistory == tplNo: // Même au dernier tour le raisonnement n'apparaît pas : c'est le moteur // (ou le gabarit) qui ignore reasoning_content, pas un tri par position. + r.RendersReasoning = tplNo notes = append(notes, "reasoning_content jamais rendu") } r.Note = strings.Join(notes, " ; ") diff --git a/internal/loki/chat_tplprobe_test.go b/internal/loki/chat_tplprobe_test.go index e2a584b..59714c4 100644 --- a/internal/loki/chat_tplprobe_test.go +++ b/internal/loki/chat_tplprobe_test.go @@ -45,7 +45,8 @@ func freshTplProbe(t *testing.T) { // // qwen3 : le raisonnement n'est rendu qu'après le dernier message utilisateur ; // keep : il est toujours rendu ; -// noagp : comme keep, mais add_generation_prompt est ignoré (vieux moteur). +// noagp : comme keep, mais add_generation_prompt est ignoré (vieux moteur) ; +// drop : reasoning_content n'est jamais rendu. type fakeTpl struct { mu sync.Mutex mode string @@ -74,7 +75,7 @@ func (f *fakeTpl) render(body map[string]any) (string, int) { m := raw.(map[string]any) role, _ := m["role"].(string) b.WriteString("<|im_start|>" + role + "\n") - if r, _ := m["reasoning_content"].(string); r != "" && role == "assistant" && (f.mode != "qwen3" || i > lastUser) { + if r, _ := m["reasoning_content"].(string); r != "" && role == "assistant" && f.mode != "drop" && (f.mode != "qwen3" || i > lastUser) { b.WriteString("" + r + "") } c, _ := m["content"].(string) @@ -182,7 +183,7 @@ func TestSondeGabaritQwen3(t *testing.T) { f.start(t) kw := map[string]any{"enable_thinking": false} r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), kw, "")) - if r.PreservesHistory != tplNo || r.PrefixStable != tplNo { + if r.PreservesHistory != tplNo || r.PrefixStable != tplNo || r.RendersReasoning != tplYes { t.Fatalf("Qwen3 retire le raisonnement passé : %+v", r) } if r.Build != "b7000-abc" || r.Model != "Qwen3-8B-Q8_0.gguf" || r.Tools != 1 || !r.cacheable { @@ -239,7 +240,7 @@ func TestSondeGabaritConserveLHistorique(t *testing.T) { f := &fakeTpl{mode: "keep"} f.start(t) r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, "")) - if r.PreservesHistory != tplYes || r.PrefixStable != tplYes { + if r.PreservesHistory != tplYes || r.PrefixStable != tplYes || r.RendersReasoning != tplYes { t.Fatalf("gabarit qui garde tout : %+v", r) } for _, a := range f.applies { @@ -249,6 +250,22 @@ func TestSondeGabaritConserveLHistorique(t *testing.T) { } } +// Gabarit (ou moteur) qui ignore reasoning_content partout : « jamais rendu », +// ce qui suffit à REASONING_ECHO pour ne rien renvoyer d'inutile. +func TestSondeGabaritRaisonnementJamaisRendu(t *testing.T) { + testHome(t) + freshTplProbe(t) + f := &fakeTpl{mode: "drop"} + f.start(t) + r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, "")) + if r.RendersReasoning != tplNo || r.PreservesHistory != tplNo || !strings.Contains(r.Note, "jamais rendu") { + t.Fatalf("raisonnement jamais rendu : %+v", r) + } + if !strings.Contains(tplProbeLine(r), "raisonnement_rendu=no") { + t.Fatalf("journal : %s", tplProbeLine(r)) + } +} + // Un moteur qui ignore add_generation_prompt ne doit pas faire conclure // « instable » : l'amorce rendue dans les deux cas fausserait la comparaison. func TestSondeGabaritAmorceIgnoree(t *testing.T) { @@ -270,7 +287,7 @@ func TestSondeGabarit404(t *testing.T) { f := &fakeTpl{mode: "qwen3", applyStatus: []int{404, 404, 404, 404}, applyBody: "Not Found"} f.start(t) r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, "")) - if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown || !r.cacheable { + if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown || r.RendersReasoning != tplUnknown || !r.cacheable { t.Fatalf("404 : %+v", r) } f.assertNoCompletion(t) diff --git a/internal/loki/code_verify.go b/internal/loki/code_verify.go index 2b5002c..62dd2ad 100644 --- a/internal/loki/code_verify.go +++ b/internal/loki/code_verify.go @@ -166,11 +166,29 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur } var content strings.Builder sawUsage := false + // newBase / echo : comme generate — vue modèle publiée par une compaction + // survenue pendant la passe (elle REMPLACE l'historique, tour compris), et + // raisonnement de la réponse finale (REASONING_ECHO). + var newBase []Message + var echo *ReasoningEcho sent, tools := prepareTurn(final, caps) extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool { if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 { sawUsage = true } + if ev.NewHistory != nil { + // Préfixe système injecté retiré, comme dans generate : il n'appartient + // pas à l'historique persisté. Ignorer cet événement laissait la + // compaction perdue et c.Messages incohérent (fil complet + tour). + base := ev.NewHistory + for len(base) > 0 && base[0].Role == "system" { + base = base[1:] + } + newBase = append([]Message(nil), base...) + } + if ev.Echo != nil { + echo = ev.Echo + } if ev.Content != "" { content.WriteString(ev.Content) } @@ -185,9 +203,12 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur }) c.mu.Lock() if c.epoch == epoch { + if newBase != nil { + c.Messages = newBase + } c.Messages = append(c.Messages, extra...) if s := content.String(); strings.TrimSpace(s) != "" { - c.Messages = append(c.Messages, Message{Role: "assistant", Content: s}) + c.Messages = append(c.Messages, withEcho(Message{Role: "assistant", Content: s}, echo)) } c.Messages = dropStrayNudges(c.Messages) // même règle que generate if sawUsage { @@ -202,8 +223,10 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur } // forwardStream relaie les événements d'un runChat secondaire (vérification, -// correction) vers le journal d'affichage — même mapping que generate(), sans -// la gestion de compaction (ces passes n'en déclenchent pas : contexte court). +// correction) vers le journal d'affichage — même mapping que generate(). Ce +// qui touche à l'HISTORIQUE (NewHistory, Echo) n'a rien à afficher : c'est +// l'appelant qui le range (runBuilderTurn), ou l'ignore pour une passe isolée +// sur sa propre trace (vérificateur). // // isolated : la passe tourne sur sa propre trace (vérificateur), PAS sur // l'historique de la discussion. Ses comptes ne disent rien du contexte de @@ -212,6 +235,14 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur // chiffre-là. La jauge de l'UI ne doit pas bouger non plus (ctx_isolated). func (c *Conversation) forwardStream(ev StreamEvent, epoch int, isolated bool) { switch { + case ev.NewHistory != nil, ev.Echo != nil: + // Rangés par l'appelant, voir plus haut. + case ev.Compacting != nil: + // Même bannière que generate. Pas pour une passe isolée : ce n'est pas + // la discussion qu'elle compacte. + if !isolated { + c.appendDelta(epoch, map[string]any{"compacting": *ev.Compacting}) + } case ev.Err != nil: c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()}) case ev.ToolUsed != nil: diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index ce36ebd..bc5fad7 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -25,6 +25,13 @@ type Message struct { Content any `json:"content,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` + // ReasoningContent : raisonnement que le moteur a séparé pour ce message + // assistant, gardé seulement avec REASONING_ECHO=on (llm_reasoning_echo.go). + // ReasoningModel : le modèle qui l'a produit. Les deux sont persistés avec la + // discussion ; à l'envoi, echoMessages retire toujours l'étiquette, et le + // raisonnement quand il ne doit pas partir. Vides : JSON inchangé. + ReasoningContent string `json:"reasoning_content,omitempty"` + ReasoningModel string `json:"reasoning_model,omitempty"` } type ToolCall struct { @@ -506,8 +513,8 @@ func EnabledTools(caps Caps) []Tool { } // StreamEvent is what a ChatCallback receives for each piece of streamed output. -// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning} is set -// per call. +// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning, Echo} +// is set per call. type StreamEvent struct { Content string Reasoning string @@ -532,6 +539,11 @@ type StreamEvent struct { // REMPLACER son historique par elle (préfixe système injecté retiré), pas l'y // ajouter. NewHistory []Message + // Echo : raisonnement séparé de la complétion qui porte la réponse finale, + // à ranger avec elle (withEcho). Émis une fois, en fin de tour, et seulement + // avec REASONING_ECHO=on sur le moteur local : les autres tours n'en voient + // jamais. Les messages tool_calls du tour portent déjà le leur (extra). + Echo *ReasoningEcho } // AskEvent : l'outil ask — question structurée posée à l'utilisateur, rendue @@ -902,12 +914,15 @@ type StatsEvent struct { // `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage. PromptTokensTotal int `json:"prompt_tokens_total,omitempty"` // Morceaux de raisonnement reçus SÉPARÉS par le moteur (reasoning_content), - // llama-server local seulement. Loki ne renvoie jamais ce raisonnement au - // modèle (Message n'a pas de champ pour lui) : ces jetons font partie de - // GenTokens mais pas de la requête suivante. Un morceau vaut au plus un - // jeton, donc ce compte ne peut que sous-estimer — le sens sans danger : on - // compacte au pire comme avant, jamais trop tard. Voir ctxAfter. + // llama-server local seulement. Sans REASONING_ECHO, Loki ne renvoie jamais + // ce raisonnement au modèle : ces jetons font partie de GenTokens mais pas de + // la requête suivante. Un morceau vaut au plus un jeton, donc ce compte ne + // peut que sous-estimer — le sens sans danger : on compacte au pire comme + // avant, jamais trop tard. Voir ctxAfter. ReasoningTokens int `json:"reasoning_tokens,omitempty"` + // echoed : ce raisonnement-là repart dans la requête suivante + // (REASONING_ECHO) — ctxAfter ne le retire alors pas. Jamais sérialisé. + echoed bool // Conseil ponctuel quand le cache de prompts n'a pas tenu après un travail // annexe (voir noteEnginePrompt). Vide la plupart du temps. CacheHint string `json:"cache_hint,omitempty"` @@ -932,7 +947,12 @@ type StatsEvent struct { // raisonnement, jamais renvoyé. Compter ce raisonnement gonflait le contexte de // 1 à 8 k jetons par étape, et la compaction (avec perte) partait trop tôt. // Borné par GenTokens : le compte de morceaux ne retire jamais plus que généré. +// Raisonnement renvoyé (echoed) : il fait partie de la requête suivante, il +// reste compté. func (s StatsEvent) ctxAfter() int { + if s.echoed { + return s.PromptTokensTotal + s.GenTokens + } return s.PromptTokensTotal + s.GenTokens - min(s.GenTokens, s.ReasoningTokens) } @@ -1199,6 +1219,12 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera // entier. const maxStreamRetries = 5 streamRetries := 0 + // REASONING_ECHO (llm_reasoning_echo.go). echoOffTurn : plus aucun + // raisonnement ne part de ce tour (prompt trop long, ou gabarit qui l'a + // refusé) — retour exact à la requête d'avant la clé. echoTplRetry : relance + // en cours après une erreur de gabarit ; si elle passe, c'était bien le + // raisonnement, et le refus est retenu pour ce modèle. + echoOffTurn, echoTplRetry := false, false // Destination des complétions : llama-server local, ou une API OpenAI-compatible // externe si le preset actif en est un (backend_external.go). Résolu UNE FOIS // par tour — une bascule de preset en plein tour est rare, et se rejoue de @@ -1251,6 +1277,14 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if toolChoiceNone { sent = withTrailingHint(sent, toolsOffHint) } + // Raisonnement renvoyé : décidé ici, au point de sortie unique, pour + // chaque requête. Clé absente (ou preset externe, ou repli en cours) : + // echoMessages rend la tranche telle quelle, requête inchangée. + pol := currentEchoPolicy() + if ep.External || echoOffTurn { + pol = echoPolicy{} + } + sent, echoSent := echoMessages(sent, pol) payload := map[string]any{ "model": ep.Model, // Les images de l'historique y sont rangées par référence @@ -1336,6 +1370,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if msg == "" { msg = resp.Status } + // La relance sans raisonnement est refusée à son tour : il n'y était + // pour rien, aucun refus à retenir pour ce modèle. + echoTplRetry = false // Refus du niveau de raisonnement par le gabarit du modèle (Qwen3.8 ne // connaît pas « high », gpt-oss ne connaît pas « xhigh »…). C'est un // refus DÉFINITIF, pas une question de taille de prompt : à traiter @@ -1352,12 +1389,36 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera continue } } + // Gabarit qui refuse le raisonnement renvoyé (gpt-oss : « Cannot pass + // both content and thinking », message assistant jugé invalide…) : + // rejoué UNE fois sans lui, AVANT la coupure des outils et la consigne + // système, qui casseraient le tour et tout le cache de prompt. + if echoSent > 0 && echoTemplateError(resp.StatusCode, msg) { + echoOffTurn, echoTplRetry = true, true + logCtx("erreur de gabarit avec le raisonnement renvoyé (%d) : relance sans lui", resp.StatusCode) + continue + } + // Prompt trop long alors que du raisonnement est parti : on retente + // d'abord sans lui — exactement la requête d'avant REASONING_ECHO —, + // avant toute compaction ou réduction, qui perdent de l'information. + if echoSent > 0 && contextOverflow(msg, messages) { + echoOffTurn = true + logCtx("prompt trop long avec le raisonnement renvoyé : relance sans lui avant toute compaction") + continue + } // Le prompt a peut-être dépassé la fenêtre de contexte : on tente une // compaction en vol et on rejoue le tour (une seule fois) avant tout le // reste. C'est le filet de secours à la Hermes. // ⚠️ Seulement si l'erreur est VRAIMENT un débordement de contexte : avant, // n'importe quel refus (appel d'outil mal formé, modèle en chargement, // erreur de template…) résumait ~75 % de la conversation, même courte. + // Raisonnement déjà retiré de ce tour (repli ci-dessus) : la réduction + // travaille sur ce qui part vraiment. Sinon elle compterait — puis + // « retirerait » — du raisonnement qui ne partait plus, et gâcherait + // une relance sur une requête identique. + if echoOffTurn { + messages = stripReasoning(messages) + } if compactEnabled() && !compactedRetry && contextOverflow(msg, messages) { if c, changed := compactMessages(ctx, messages, caps); changed { compactedRetry = true @@ -1439,6 +1500,12 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera // Une réponse est arrivée : le budget de reprise réseau repart à neuf pour // la suite de la boucle d'outils. netRetries = 0 + // La relance sans raisonnement passe : c'était bien lui que le gabarit + // refusait. Retenu pour ce modèle, plus de 500 à repayer à chaque tour. + if echoTplRetry { + echoTplRetry = false + echoRefuse(reasoningEchoModel(ReadConfig())) + } if !ep.External { engineServed() // le slot porte désormais cette requête (llm_slots.go) } @@ -1492,6 +1559,10 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera } // Per-completion reasoning-split state (see reasoningOn comment above). sawReasoningField := false + // Raisonnement séparé PAR LE SERVEUR, gardé pour être renvoyé + // (REASONING_ECHO). Jamais celui découpé chez nous sur « » : il + // reste dans le contenu, où le gabarit le redécoupe lui-même. + var echoBuf strings.Builder thinkOpen := reasoningOn var thinkTail strings.Builder // Scanner à gros tampon : un chunk peut porter un gros JSON d'arguments @@ -1719,6 +1790,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if !ep.External { stats.ReasoningTokens++ } + if pol.on { + echoBuf.WriteString(ch.Delta.ReasoningContent) + } if !scb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) { aborted = true break @@ -1824,6 +1898,17 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera scanErr := sc.Err() resp.Body.Close() endReq() + // Raisonnement de cette complétion à renvoyer, s'il y a lieu. Il repart + // dans la requête suivante quand elle continue ce tour (appel d'outil), + // ou au tour suivant si le gabarit garde le passé : ctxAfter le compte — + // posé avant toute émission de stats de cette complétion. + echoText := "" + if pol.on && sawReasoningField { + echoText = echoBuf.String() + } + if echoText != "" && (len(toolCalls) > 0 || pol.keepsPast) { + stats.echoed = true + } // Premier jeton, de quelque nature qu'il soit (raisonnement, texte ou // appel d'outil). tReq est repris à chaque tentative. if !tFirst.IsZero() { @@ -1971,6 +2056,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if s := assistantContent.String(); s != "" { assistant.Content = s } + if echoText != "" { + assistant.ReasoningContent, assistant.ReasoningModel = echoText, pol.model + } result := preflight.msg + " (écriture interrompue avant la fin : rien n'a été modifié)" cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: preflight.name, Label: preflight.file, Done: true, Result: result}}) toolMsg := Message{Role: "tool", ToolCallID: id, Content: result} @@ -2025,6 +2113,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if s := assistantContent.String(); s != "" { assistant.Content = s } + if echoText != "" { + assistant.ReasoningContent, assistant.ReasoningModel = echoText, pol.model + } messages = append(messages, assistant) extra = append(extra, assistant) // Début des résultats de CETTE étape, que le moteur n'a pas encore @@ -2355,7 +2446,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if snippet := textualToolCallSnippet(assistantContent.String()); snippet != "" && patternRetries < maxPatternRetries && callsOn { patternRetries++ - bad := Message{Role: "assistant", Content: assistantContent.String()} + bad := withEcho(Message{Role: "assistant", Content: assistantContent.String()}, &ReasoningEcho{Text: echoText, Model: pol.model}) fix := Message{Role: "user", Content: retryCorrective(snippet)} messages = append(messages, bad, fix) extra = append(extra, bad, fix) @@ -2412,6 +2503,12 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera } cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"}) } + // Raisonnement de la réponse finale : l'appelant le range avec elle. + // Jamais sans texte (un message assistant de pur raisonnement serait + // refusé par llama.cpp). + if echoText != "" && strings.TrimSpace(assistantContent.String()) != "" { + cb(StreamEvent{Echo: &ReasoningEcho{Text: echoText, Model: pol.model}}) + } return extra, nil } } diff --git a/internal/loki/llm_reasoning_echo.go b/internal/loki/llm_reasoning_echo.go new file mode 100644 index 0000000..668e09d --- /dev/null +++ b/internal/loki/llm_reasoning_echo.go @@ -0,0 +1,268 @@ +package loki + +// llm_reasoning_echo.go — REASONING_ECHO : renvoyer au moteur LOCAL le +// raisonnement que le modèle a lui-même produit (reasoning_content), au lieu de +// lui montrer ses étapes passées avec des blocs de réflexion vides. +// +// Pourquoi c'est utile. Les gabarits Qwen3.5/3.6 (et d'autres) rendent +// `…` pour chaque message assistant situé après la dernière +// question de l'utilisateur : c'est le format entrelacé sur lequel ils ont été +// entraînés. Loki ne gardait que le texte et les appels d'outils, donc à chaque +// étape d'une boucle d'outils le modèle relisait ses étapes précédentes sans leur +// raisonnement, et le moteur recalculait le dernier message assistant (le rendu +// ne correspondait plus à ce qu'il avait généré). Avec un gabarit qui sait +// conserver la réflexion des tours passés (REASONING_PRESERVE=on, Qwen3.6), le +// préfixe reste en plus stable d'un message utilisateur à l'autre. Qwen3.5 n'a +// pas ce réglage : le gain y reste interne au tour. +// +// Pourquoi c'est OPT-IN (off par défaut). Le raisonnement renvoyé occupe du +// contexte : une longue boucle d'outils atteint la compaction (avec perte) plus +// tôt, même sans REASONING_PRESERVE. Et certains gabarits le refusent (gpt-oss +// sur un moteur ancien lève « Cannot pass both content and thinking »). Sans la +// clé, rien ne change : rien n'est capturé, rien n'est envoyé, à l'octet près. +// +// Les règles, chacune contre un piège précis : +// - moteur local seulement : une API externe (OpenAI, DeepSeek) refuserait ou +// interpréterait le champ à sa façon ; +// - seulement le raisonnement que le SERVEUR a séparé (reasoning_content du +// flux). Le découpage « » fait chez nous reste dans le contenu, où +// le gabarit le redécoupe lui-même : poser aussi ReasoningContent le ferait +// rendre deux fois ; +// - chaque raisonnement porte le nom du modèle qui l'a produit, et ne repart +// que vers ce modèle-là : après un changement de modèle, un gabarit qui rend +// les anciens raisonnements (DeepSeek-V4 avec outils, MiniMax) présenterait +// sinon la réflexion d'une autre famille comme la sienne ; +// - jamais de message assistant qui ne porterait que du raisonnement (llama.cpp +// répond « Expected 'content' or 'tool_calls' ») ; +// - un seul point de sortie (echoMessages, appelé par runChat juste avant le +// marshal) : c'est là que tout est retiré quand la règle ne s'applique pas, +// y compris l'étiquette du modèle, qui ne quitte jamais Loki ; +// - si le gabarit refuse (exception), le tour est rejoué UNE fois sans +// raisonnement, et le refus est retenu pour ce modèle jusqu'au redémarrage, +// avant toute coupure d'outils ; +// - si le prompt déborde, on retente d'abord sans raisonnement — exactement la +// requête d'avant cette clé — avant toute compaction. + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// reasoningEchoEnabled : la clé REASONING_ECHO demande-t-elle l'écho ? +func reasoningEchoEnabled(cfg map[string]string) bool { + switch strings.ToLower(strings.TrimSpace(cfg["REASONING_ECHO"])) { + case "on", "1", "true", "yes", "oui": + return true + } + return false +} + +// reasoningEchoModel : l'étiquette du modèle chargé, posée sur chaque +// raisonnement capturé. Le nom du fichier, pas le chemin : le même modèle +// déplacé ou monté ailleurs reste le même. Vide = inconnu, rien ne part. +func reasoningEchoModel(cfg map[string]string) string { + m := strings.TrimSpace(cfg["MODEL"]) + if m == "" { + return "" + } + return filepath.Base(m) +} + +// echoRefused : modèles dont le gabarit a refusé un raisonnement renvoyé, pour +// toute la vie du processus (même principe que effortFallbacks). +var echoRefused sync.Map + +func echoRefuse(model string) { + if model == "" { + return + } + if _, loaded := echoRefused.LoadOrStore(model, true); !loaded { + fmt.Fprintf(os.Stderr, "[reasoning] gabarit de %s : raisonnement renvoyé refusé — REASONING_ECHO suspendu pour ce modèle jusqu'au redémarrage\n", model) + } +} + +func echoIsRefused(model string) bool { + _, ok := echoRefused.Load(model) + return ok +} + +// echoPolicy : ce qui part au moteur pour cette requête. +// - on : renvoyer le raisonnement (clé posée, moteur local, modèle connu, pas +// refusé par le gabarit, et la sonde ne dit pas qu'il n'est jamais rendu) ; +// - model : seul le raisonnement étiqueté de ce modèle repart ; +// - keepsPast : le gabarit rend aussi le raisonnement des messages d'AVANT la +// dernière question (sonde « oui » ou inconnue). Ne sert qu'au comptage : +// un raisonnement que le gabarit écarte ne coûte rien au prompt. +type echoPolicy struct { + on bool + model string + keepsPast bool +} + +// currentEchoPolicy lit la configuration, la mémoire des refus et le dernier +// verdict de la sonde de gabarit (chat_tplprobe.go). +func currentEchoPolicy() echoPolicy { + return echoPolicyFor(ReadConfig()) +} + +func echoPolicyFor(cfg map[string]string) echoPolicy { + p := echoPolicy{model: reasoningEchoModel(cfg), keepsPast: true} + if !reasoningEchoEnabled(cfg) || isExternalConfig(cfg) || p.model == "" || echoIsRefused(p.model) { + return echoPolicy{} + } + p.on = true + // Verdict de la sonde, s'il concerne bien CE modèle. « jamais rendu » : + // renvoyer ne servirait à rien, on s'abstient. Inconnu : l'utilisateur a + // demandé l'écho, on envoie (le filet d'erreur de gabarit reste là). + if r, ok := tplCapsCurrent(); ok && r.Model == p.model { + if r.RendersReasoning == tplNo { + p.on = false + } + if r.PreservesHistory == tplNo { + p.keepsPast = false + } + } + return p +} + +// hasBody : le message assistant porte du texte ou des appels d'outils — ce +// sans quoi llama.cpp refuse un message assistant. +func hasBody(m Message) bool { + if len(m.ToolCalls) > 0 { + return true + } + switch v := m.Content.(type) { + case nil: + return false + case string: + return v != "" + } + return true +} + +// echoable : ce raisonnement-là peut-il partir sous cette politique ? +func echoable(m Message, p echoPolicy) bool { + return p.on && m.Role == "assistant" && m.ReasoningContent != "" && + m.ReasoningModel == p.model && hasBody(m) +} + +// echoMessages : LE point de sortie. Rend les messages tels qu'ils partent au +// moteur, et le nombre de raisonnements renvoyés. Aucun message ne porte de +// raisonnement (cas de toujours, clé absente) : la tranche est rendue telle +// quelle, sans copie — la requête est identique à l'octet près. Sinon, copie : +// l'étiquette du modèle est toujours retirée, le raisonnement l'est quand il +// ne doit pas partir. +func echoMessages(msgs []Message, p echoPolicy) ([]Message, int) { + has := false + for i := range msgs { + if msgs[i].ReasoningContent != "" || msgs[i].ReasoningModel != "" { + has = true + break + } + } + if !has { + return msgs, 0 + } + out := make([]Message, len(msgs)) + n := 0 + for i, m := range msgs { + keep := echoable(m, p) + m.ReasoningModel = "" + if keep { + n++ + } else { + m.ReasoningContent = "" + } + out[i] = m + } + return out, n +} + +// echoTokens : jetons que le raisonnement renvoyé ajoute au prompt, message par +// message (nil = aucun). Compte ce qui part ET que le gabarit rend : après la +// dernière question toujours (rendu au sein du tour par tous les gabarits +// entrelacés), avant elle seulement si le gabarit conserve le passé ou qu'on ne +// le sait pas — dans le doute on compte, le sens sans danger. +func echoTokens(msgs []Message) []int { + found := false + for i := range msgs { + if msgs[i].ReasoningContent != "" { + found = true + break + } + } + if !found { + return nil // cas de toujours : pas même une lecture de configuration + } + return echoTokensFor(msgs, currentEchoPolicy()) +} + +func echoTokensFor(msgs []Message, p echoPolicy) []int { + if !p.on { + return nil + } + lastUser := -1 + for i, m := range msgs { + if m.Role == "user" { + lastUser = i + } + } + var out []int + for i, m := range msgs { + if !echoable(m, p) || (i < lastUser && !p.keepsPast) { + continue + } + if out == nil { + out = make([]int, len(msgs)) + } + out[i] = len(m.ReasoningContent) / 4 + } + return out +} + +// stripReasoning : copie des messages sans aucun raisonnement (étiquette +// comprise) — ce que Loki envoyait avant REASONING_ECHO. +func stripReasoning(msgs []Message) []Message { + out, _ := echoMessages(msgs, echoPolicy{}) + return out +} + +// echoTemplateError : le moteur a-t-il refusé la requête à cause du gabarit, +// d'une façon que le raisonnement renvoyé peut expliquer ? Exception levée par +// le gabarit (raise_exception, « Cannot pass both content and thinking » de +// gpt-oss), message assistant jugé invalide, messages illisibles. Le refus du +// niveau de raisonnement, lui aussi levé par le gabarit, a son propre filet +// (llm_effort.go) et n'est pas concerné. +func echoTemplateError(status int, body string) bool { + if status != 500 && status != 400 { + return false + } + if _, isEffort := effortRejection(body); isEffort { + return false + } + low := strings.ToLower(body) + for _, k := range []string{"raise_exception", "thinking", "expected 'content' or 'tool_calls'", "failed to parse messages"} { + if strings.Contains(low, k) { + return true + } + } + return false +} + +// ReasoningEcho : raisonnement séparé de la complétion qui porte la réponse +// finale, et le modèle qui l'a produit. L'appelant le range avec cette réponse. +type ReasoningEcho struct { + Text string + Model string +} + +// withEcho pose le raisonnement final sur le message de réponse. +func withEcho(m Message, e *ReasoningEcho) Message { + if e != nil && e.Text != "" && hasBody(m) { + m.ReasoningContent, m.ReasoningModel = e.Text, e.Model + } + return m +} diff --git a/internal/loki/llm_reasoning_echo_test.go b/internal/loki/llm_reasoning_echo_test.go new file mode 100644 index 0000000..e085c54 --- /dev/null +++ b/internal/loki/llm_reasoning_echo_test.go @@ -0,0 +1,596 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" +) + +const echoTestModel = "Qwen3.6-27B-Q8_0.gguf" + +// echoTestSetup : base de test, modèle connu, sonde vierge, mémoire des refus +// nettoyée. on = REASONING_ECHO posé. +func echoTestSetup(t *testing.T, on bool) { + t.Helper() + withWorkspace(t) + freshTplProbe(t) + if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil { + t.Fatal(err) + } + if on { + if err := SetConfigKey("REASONING_ECHO", "on"); err != nil { + t.Fatal(err) + } + } + echoRefused.Delete(echoTestModel) + t.Cleanup(func() { echoRefused.Delete(echoTestModel) }) +} + +// moteurEcho : llama-server de comédie. Chaque requête de complétion reçoit +// la réponse de reply (statut, corps SSE ou erreur) ; les corps bruts sont +// gardés pour être comparés. Les requêtes non streamées (résumé de compaction) +// reçoivent un résumé. +type moteurEcho struct { + mu sync.Mutex + bodies []string + summary int + reply func(n int, body string) (int, string) +} + +func (m *moteurEcho) start(t *testing.T) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + _, _ = buf.ReadFrom(r.Body) + body := buf.String() + if !strings.Contains(body, `"stream":true`) { + m.mu.Lock() + m.summary++ + m.mu.Unlock() + sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé des étapes précédentes, avec les faits trouvés"}}}}) + return + } + m.mu.Lock() + n := len(m.bodies) + m.bodies = append(m.bodies, body) + m.mu.Unlock() + status, out := m.reply(n, body) + if status != 200 { + w.WriteHeader(status) + _, _ = w.Write([]byte(out)) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(out + "data: [DONE]\n\n")) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } +} + +func (m *moteurEcho) all() []string { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.bodies...) +} + +const sseGlobStep = `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"g1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n" + + `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n" + +// Une boucle d'outils : raisonnement séparé + appel, puis raisonnement + réponse. +func scriptBoucle(n int, _ string) (int, string) { + if n == 0 { + return 200, sseReasoning("je cherche ") + sseReasoning("les fichiers") + sseGlobStep + } + return 200, sseReasoning("j'ai trouvé") + sseChunk("Voilà.") + sseFinal("stop", 120, 5) +} + +// requestMessages décode les messages d'un corps de requête. +func requestMessages(t *testing.T, body string) []map[string]any { + t.Helper() + var p struct { + Messages []map[string]any `json:"messages"` + } + if err := json.Unmarshal([]byte(body), &p); err != nil { + t.Fatal(err) + } + return p.Messages +} + +// Clé absente : rien n'est capturé ni renvoyé, aucun événement nouveau. La +// requête est celle d'avant la clé. +func TestEchoDefautRienNeChange(t *testing.T) { + echoTestSetup(t, false) + m := &moteurEcho{reply: scriptBoucle} + m.start(t) + var echoes int + extra, err := runChat(context.Background(), []Message{um("liste les fichiers")}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool { + if ev.Echo != nil { + echoes++ + } + return true + }) + if err != nil { + t.Fatal(err) + } + bodies := m.all() + if len(bodies) != 2 { + t.Fatalf("%d requêtes, attendu 2", len(bodies)) + } + for i, b := range bodies { + if strings.Contains(b, "reasoning_content") || strings.Contains(b, "reasoning_model") { + t.Fatalf("requête %d : raisonnement envoyé sans la clé : %s", i, b) + } + } + for _, e := range extra { + if e.ReasoningContent != "" || e.ReasoningModel != "" { + t.Fatalf("raisonnement capturé sans la clé : %+v", e) + } + } + if echoes != 0 { + t.Fatalf("%d événements Echo sans la clé", echoes) + } +} + +// Point de sortie : sans raisonnement nulle part, la tranche ressort telle +// quelle ; avec du raisonnement stocké mais une politique éteinte, le JSON est +// octet pour octet celui d'avant le champ. +func TestEchoMessagesOctetPourOctet(t *testing.T) { + plain := []Message{ + {Role: "system", Content: "sys"}, + um("question"), + {Role: "assistant", Content: "je regarde", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "bash", Arguments: "{}"}}}}, + tm("sortie"), + am("réponse"), + } + out, n := echoMessages(plain, echoPolicy{on: true, model: echoTestModel, keepsPast: true}) + if n != 0 || &out[0] != &plain[0] { + t.Fatal("aucun raisonnement : la tranche doit ressortir sans copie") + } + want, _ := json.Marshal(plain) + stored := append([]Message(nil), plain...) + stored[2].ReasoningContent, stored[2].ReasoningModel = "raisonnement", echoTestModel + stored[4].ReasoningContent, stored[4].ReasoningModel = "autre", echoTestModel + for _, p := range []echoPolicy{{}, {on: true, model: "autre-modele.gguf", keepsPast: true}} { + got, n := echoMessages(stored, p) + b, _ := json.Marshal(got) + if n != 0 || !bytes.Equal(b, want) { + t.Fatalf("politique %+v : %s\nattendu %s", p, b, want) + } + } + if stored[2].ReasoningContent == "" { + t.Fatal("echoMessages a modifié l'historique d'origine") + } +} + +// Ce qui part : seulement le raisonnement du même modèle, jamais l'étiquette, +// jamais sur un message assistant sans texte ni appel. +func TestEchoMessagesPolitique(t *testing.T) { + p := echoPolicy{on: true, model: echoTestModel, keepsPast: true} + msgs := []Message{ + um("q"), + {Role: "assistant", Content: "a", ReasoningContent: "r1", ReasoningModel: echoTestModel}, + {Role: "assistant", Content: "b", ReasoningContent: "r2", ReasoningModel: "Llama-3.gguf"}, + {Role: "assistant", ReasoningContent: "r3", ReasoningModel: echoTestModel}, // ni texte ni appel + {Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "bash", Arguments: "{}"}}}, ReasoningContent: "r4", ReasoningModel: echoTestModel}, + {Role: "user", Content: "x", ReasoningContent: "r5", ReasoningModel: echoTestModel}, + } + out, n := echoMessages(msgs, p) + if n != 2 || out[1].ReasoningContent != "r1" || out[4].ReasoningContent != "r4" { + t.Fatalf("renvoyés : %d, %+v", n, out) + } + for i, m := range out { + if m.ReasoningModel != "" { + t.Fatalf("étiquette du modèle envoyée (message %d)", i) + } + if m.Role == "assistant" && m.ReasoningContent != "" && !hasBody(m) { + t.Fatalf("message assistant de pur raisonnement envoyé (message %d)", i) + } + } + if out[2].ReasoningContent != "" || out[3].ReasoningContent != "" || out[5].ReasoningContent != "" { + t.Fatalf("raisonnement envoyé à tort : %+v", out) + } +} + +func TestEchoPolicyFor(t *testing.T) { + echoTestSetup(t, false) + base := map[string]string{"REASONING_ECHO": "on", "MODEL": "/models/" + echoTestModel} + with := func(k, v string) map[string]string { + c := map[string]string{} + for a, b := range base { + c[a] = b + } + c[k] = v + return c + } + if p := echoPolicyFor(base); !p.on || p.model != echoTestModel || !p.keepsPast { + t.Fatalf("clé posée : %+v", p) + } + if p := echoPolicyFor(with("REASONING_ECHO", "")); p.on { + t.Fatal("clé absente : écho actif") + } + if p := echoPolicyFor(with(extKeyFlag, "1")); p.on { + t.Fatal("preset externe : écho actif") + } + if p := echoPolicyFor(with("MODEL", "")); p.on { + t.Fatal("modèle inconnu : écho actif") + } + echoRefuse(echoTestModel) + if p := echoPolicyFor(base); p.on { + t.Fatal("gabarit qui a refusé : écho actif") + } + echoRefused.Delete(echoTestModel) + // Verdicts de la sonde, seulement pour CE modèle. + set := func(r tplProbeResult) { + tplProbeMu.Lock() + tplProbeLast = &r + tplProbeMu.Unlock() + } + set(tplProbeResult{Model: echoTestModel, RendersReasoning: tplNo, PreservesHistory: tplNo}) + if p := echoPolicyFor(base); p.on { + t.Fatal("gabarit qui ne rend jamais le raisonnement : écho actif") + } + set(tplProbeResult{Model: echoTestModel, RendersReasoning: tplYes, PreservesHistory: tplNo}) + if p := echoPolicyFor(base); !p.on || p.keepsPast { + t.Fatalf("gabarit façon Qwen3.5 : %+v", p) + } + set(tplProbeResult{Model: "autre.gguf", RendersReasoning: tplNo, PreservesHistory: tplNo}) + if p := echoPolicyFor(base); !p.on || !p.keepsPast { + t.Fatalf("verdict d'un autre modèle appliqué : %+v", p) + } +} + +// Comptage : le raisonnement d'après la dernière question compte toujours, +// celui d'avant seulement si le gabarit garde le passé (ou qu'on l'ignore). +func TestEchoTokensSuitLeRendu(t *testing.T) { + r := strings.Repeat("r", 400) // 100 jetons + msgs := []Message{ + um("q1"), + {Role: "assistant", Content: "a", ReasoningContent: r, ReasoningModel: echoTestModel}, + um("q2"), + {Role: "assistant", Content: "b", ReasoningContent: r, ReasoningModel: echoTestModel}, + } + sum := func(v []int) int { + s := 0 + for _, n := range v { + s += n + } + return s + } + if got := sum(echoTokensFor(msgs, echoPolicy{on: true, model: echoTestModel, keepsPast: true})); got != 200 { + t.Fatalf("gabarit qui garde le passé : %d, attendu 200", got) + } + if got := sum(echoTokensFor(msgs, echoPolicy{on: true, model: echoTestModel})); got != 100 { + t.Fatalf("gabarit qui retire le passé : %d, attendu 100", got) + } + if got := echoTokensFor(msgs, echoPolicy{}); got != nil { + t.Fatalf("écho coupé : %v", got) + } + // estimateTokens suit la configuration réelle. + echoTestSetup(t, false) + without := estimateTokens(msgs) + if err := SetConfigKey("REASONING_ECHO", "on"); err != nil { + t.Fatal(err) + } + if got := estimateTokens(msgs); got != without+200 { + t.Fatalf("estimateTokens avec l'écho = %d, attendu %d", got, without+200) + } +} + +// shrinkToFit retire d'abord le raisonnement, du plus ancien au plus récent, +// même dans un seul long tour sans frontière utilisateur. +func TestShrinkRetireDAbordLeRaisonnement(t *testing.T) { + echoTestSetup(t, true) + if err := SetConfigKey("CTX", "8192"); err != nil { + t.Fatal(err) + } + r := strings.Repeat("r", 8000) // 2000 jetons chacun + msgs := []Message{um("corrige le build")} + for i := 0; i < 4; i++ { + msgs = append(msgs, + Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "bash", Arguments: "{}"}}}, ReasoningContent: r, ReasoningModel: echoTestModel}, + tm("sortie courte")) + } + out, changed := shrinkToFit(msgs, 0) + if !changed { + t.Fatal("rien retiré") + } + if len(out) != len(msgs) { + t.Fatalf("des messages ont été retirés (%d → %d) alors que le raisonnement suffisait", len(msgs), len(out)) + } + if out[1].ReasoningContent != "" || out[len(out)-2].ReasoningContent == "" { + t.Fatal("ordre : le plus ancien raisonnement doit partir en premier, le plus récent rester") + } + for i, m := range out { + if m.Role == "tool" && msgText(m) != "sortie courte" { + t.Fatalf("résultat d'outil %d tronqué avant le raisonnement", i) + } + } + if msgs[1].ReasoningContent == "" { + t.Fatal("shrinkToFit a modifié l'historique d'origine") + } +} + +// Clé posée : le raisonnement séparé de l'étape d'outil repart avec elle, sans +// étiquette ; celui de la réponse finale est rendu à l'appelant. +func TestEchoRenvoieLeRaisonnementSepare(t *testing.T) { + echoTestSetup(t, true) + m := &moteurEcho{reply: scriptBoucle} + m.start(t) + var echo *ReasoningEcho + extra, err := runChat(context.Background(), []Message{um("liste les fichiers")}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool { + if ev.Echo != nil { + echo = ev.Echo + } + return true + }) + if err != nil { + t.Fatal(err) + } + if len(extra) < 1 || extra[0].ReasoningContent != "je cherche les fichiers" || extra[0].ReasoningModel != echoTestModel { + t.Fatalf("message tool_calls : %+v", extra) + } + bodies := m.all() + if len(bodies) != 2 { + t.Fatalf("%d requêtes", len(bodies)) + } + if strings.Contains(bodies[1], "reasoning_model") { + t.Fatal("étiquette du modèle envoyée au moteur") + } + msgs := requestMessages(t, bodies[1]) + var found bool + for _, mm := range msgs { + if mm["role"] == "assistant" && mm["reasoning_content"] == "je cherche les fichiers" { + found = true + } + } + if !found { + t.Fatalf("raisonnement de l'étape non renvoyé : %s", bodies[1]) + } + if echo == nil || echo.Text != "j'ai trouvé" || echo.Model != echoTestModel { + t.Fatalf("raisonnement final : %+v", echo) + } +} + +// Découpage « » fait chez nous : jamais copié dans ReasoningContent +// (le gabarit le redécouperait, rendu en double). +func TestEchoJamaisDepuisLeDecoupageClient(t *testing.T) { + echoTestSetup(t, true) + if err := SetConfigKey("REASONING", "on"); err != nil { + t.Fatal(err) + } + m := &moteurEcho{reply: func(n int, _ string) (int, string) { + if n == 0 { + return 200, sseChunk("je pense") + sseChunk("") + sseChunk("je regarde") + sseGlobStep + } + return 200, sseChunk("pensée") + sseChunk("Voilà.") + sseFinal("stop", 100, 4) + }} + m.start(t) + var echo *ReasoningEcho + extra, err := runChat(context.Background(), []Message{um("liste")}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool { + if ev.Echo != nil { + echo = ev.Echo + } + return true + }) + if err != nil { + t.Fatal(err) + } + for _, b := range m.all() { + if strings.Contains(b, "reasoning_content") { + t.Fatalf("raisonnement découpé chez nous renvoyé à part : %s", b) + } + } + if len(extra) < 1 || extra[0].ReasoningContent != "" || msgText(extra[0]) != "je penseje regarde" { + t.Fatalf("message tool_calls : %+v", extra) + } + if echo != nil { + t.Fatalf("raisonnement final tiré du découpage client : %+v", echo) + } +} + +// Une relance « pensé sans agir » (DropReasoning) : seul le raisonnement de la +// complétion retenue accompagne la réponse. +func TestEchoApresDropReasoning(t *testing.T) { + echoTestSetup(t, true) + m := &moteurEcho{reply: func(n int, _ string) (int, string) { + if n == 0 { + return 200, sseReasoning("mort-né") + sseFinal("stop", 100, 3) + } + return 200, sseReasoning("retenu") + sseChunk("Fait.") + sseFinal("stop", 110, 3) + }} + m.start(t) + var echo *ReasoningEcho + var dropped bool + if _, err := runChat(context.Background(), []Message{um("fais-le")}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool { + if ev.Echo != nil { + echo = ev.Echo + } + if ev.DropReasoning { + dropped = true + } + return true + }); err != nil { + t.Fatal(err) + } + if !dropped || echo == nil || echo.Text != "retenu" { + t.Fatalf("après relance : dropped=%v echo=%+v", dropped, echo) + } +} + +// generate range le raisonnement final avec la réponse, et rien sans la clé. +func TestGenerateRangeLeRaisonnement(t *testing.T) { + for _, on := range []bool{false, true} { + echoTestSetup(t, on) + m := &moteurEcho{reply: func(int, string) (int, string) { + return 200, sseReasoning("réflexion") + sseChunk("Bonjour.") + sseFinal("stop", 50, 3) + }} + m.start(t) + c := newTestConv() + c.Messages = []Message{um("salut")} + c.generate(context.Background(), Caps{}, 0.7, c.epoch) + last := c.Messages[len(c.Messages)-1] + if msgText(last) != "Bonjour." { + t.Fatalf("on=%v : dernier message %+v", on, last) + } + if on && (last.ReasoningContent != "réflexion" || last.ReasoningModel != echoTestModel) { + t.Fatalf("clé posée : raisonnement non rangé : %+v", last) + } + if !on && (last.ReasoningContent != "" || last.ReasoningModel != "") { + t.Fatalf("clé absente : raisonnement rangé : %+v", last) + } + } +} + +// Historique qui porte un raisonnement de ce modèle. +func echoHistory() []Message { + return []Message{ + um("question"), + {Role: "assistant", Content: "préambule", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "glob", Arguments: `{"pattern":"*"}`}}}, + ReasoningContent: "réflexion passée", ReasoningModel: echoTestModel}, + tm("a.go"), + } +} + +// Gabarit qui refuse (gpt-oss sur un moteur ancien) : rejoué une fois sans +// raisonnement, outils et prompt intacts, et le refus est retenu. +func TestEchoErreurDeGabaritRelanceSansRaisonnement(t *testing.T) { + echoTestSetup(t, true) + m := &moteurEcho{reply: func(_ int, body string) (int, string) { + if strings.Contains(body, "reasoning_content") { + return 500, `{"error":{"code":500,"message":"Jinja Exception: raise_exception('Cannot pass both content and thinking in an assistant message with tool calls')"}}` + } + return 200, sseChunk("Réponse.") + sseFinal("stop", 80, 2) + }} + m.start(t) + var content strings.Builder + if _, err := runChat(context.Background(), echoHistory(), 0.7, Caps{Agent: true}, func(ev StreamEvent) bool { + content.WriteString(ev.Content) + return true + }); err != nil { + t.Fatal(err) + } + bodies := m.all() + if len(bodies) != 2 || content.String() != "Réponse." { + t.Fatalf("%d requêtes, réponse %q", len(bodies), content.String()) + } + if strings.Contains(bodies[1], "reasoning_content") || strings.Contains(bodies[1], `"tool_choice"`) || + !strings.Contains(bodies[1], `"tools"`) { + t.Fatalf("relance : raisonnement retiré, outils intacts attendus : %s", bodies[1]) + } + if !echoIsRefused(echoTestModel) { + t.Fatal("refus du gabarit non retenu") + } + if p := currentEchoPolicy(); p.on { + t.Fatal("écho encore actif après le refus") + } +} + +// Erreur de gabarit qui n'a rien à voir avec le raisonnement : la relance +// échoue aussi, et rien n'est retenu contre l'écho. +func TestEchoErreurDeGabaritSansRapport(t *testing.T) { + echoTestSetup(t, true) + m := &moteurEcho{reply: func(int, string) (int, string) { + return 500, `{"error":{"code":500,"message":"Jinja Exception: raise_exception('Conversation roles must alternate')"}}` + }} + m.start(t) + if _, err := runChat(context.Background(), echoHistory(), 0.7, Caps{}, func(StreamEvent) bool { return true }); err == nil { + t.Fatal("erreur attendue") + } + bodies := m.all() + if len(bodies) < 2 || !strings.Contains(bodies[0], "reasoning_content") { + t.Fatalf("%d requêtes ; la première doit porter le raisonnement", len(bodies)) + } + // Ensuite, les filets habituels (tool_choice none, outils coupés) partent + // sans raisonnement : il n'est plus envoyé de ce tour. + for i, b := range bodies[1:] { + if strings.Contains(b, "reasoning_content") { + t.Fatalf("requête %d : raisonnement renvoyé après le repli", i+1) + } + } + if echoIsRefused(echoTestModel) { + t.Fatal("refus retenu alors que le raisonnement n'y était pour rien") + } +} + +// Prompt trop long avec du raisonnement : relance sans lui AVANT toute +// compaction (aucune perte d'information). +func TestEchoDebordementRelanceSansRaisonnementAvantCompaction(t *testing.T) { + echoTestSetup(t, true) + m := &moteurEcho{reply: func(_ int, body string) (int, string) { + if strings.Contains(body, "reasoning_content") { + return 400, `{"error":{"code":400,"message":"the request exceeds the available context size, try increasing it","type":"exceed_context_size_error"}}` + } + return 200, sseChunk("Réponse.") + sseFinal("stop", 80, 2) + }} + m.start(t) + var compacted bool + if _, err := runChat(context.Background(), echoHistory(), 0.7, Caps{Agent: true}, func(ev StreamEvent) bool { + if ev.NewHistory != nil { + compacted = true + } + return true + }); err != nil { + t.Fatal(err) + } + bodies := m.all() + if len(bodies) != 2 || strings.Contains(bodies[1], "reasoning_content") { + t.Fatalf("%d requêtes ; relance sans raisonnement attendue", len(bodies)) + } + m.mu.Lock() + summaries := m.summary + m.mu.Unlock() + if compacted || summaries != 0 { + t.Fatalf("compaction avant la relance sans raisonnement (résumés=%d)", summaries) + } + if echoIsRefused(echoTestModel) { + t.Fatal("un débordement n'est pas un refus du gabarit") + } +} + +// Passe de correction du mode Code : une compaction survenue pendant la passe +// REMPLACE l'historique, comme dans generate, au lieu d'être perdue. +func TestBuilderAppliqueNewHistory(t *testing.T) { + echoTestSetup(t, false) + convEnsureActive() + m := &moteurEcho{reply: func(n int, _ string) (int, string) { + if n == 0 { + return 400, `{"error":{"code":400,"message":"request (99999 tokens) exceeds the available context size"}}` + } + return 200, sseChunk("Corrigé.") + sseFinal("stop", 80, 2) + }} + m.start(t) + c := newTestConv() + c.Messages = []Message{um("fais le build")} + for i := 0; i < 12; i++ { + c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600))) + } + before := len(c.Messages) + c.runBuilderTurn(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, "Fix the failing test.") + if len(c.Messages) >= before { + t.Fatalf("compaction perdue : %d messages (avant %d)", len(c.Messages), before) + } + if !strings.HasPrefix(msgText(c.Messages[0]), compactSummaryPrefix) { + t.Fatalf("premier message : %q", msgText(c.Messages[0])) + } + if c.Messages[0].Role == "system" { + t.Fatal("préfixe système injecté persisté") + } + fix := 0 + for _, mm := range c.Messages { + if msgText(mm) == "Fix the failing test." { + fix++ + } + } + if fix != 1 || msgText(c.Messages[len(c.Messages)-1]) != "Corrigé." { + t.Fatalf("consigne présente %d fois, dernier message %q", fix, msgText(c.Messages[len(c.Messages)-1])) + } +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 31a081f..9914c99 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -94,8 +94,14 @@ var configTemplate = []struct{ key, help string }{ {"SPEC_SAMPLING", "tirage du brouillon : greedy (défaut, exact) ; probabilistic seulement avec SPEC=mtp"}, {"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"}, {"REASONING_PRESERVE", "on/off = garder ou non la réflexion des tours passés dans le gabarit (--reasoning-preserve) ; " + - "vide = défaut du moteur (on depuis b10763). Loki ne renvoie pas cette réflexion : off rend l'ancien historique " + - "aux gabarits type Qwen3.6, mais change celui des gabarits qui la gardent d'eux-mêmes (Qwen3.8)"}, + "vide = défaut du moteur (on depuis b10763). Sans REASONING_ECHO, Loki ne renvoie pas cette réflexion : off rend " + + "l'ancien historique aux gabarits type Qwen3.6, mais change celui des gabarits qui la gardent d'eux-mêmes (Qwen3.8). " + + "Avec REASONING_ECHO=on, on = préfixe stable d'un message à l'autre (mode entraîné mais optionnel selon la fiche " + + "Qwen3.6), au prix de plus de contexte par tour, donc d'une compaction plus tôt"}, + {"REASONING_ECHO", "on = renvoyer au moteur local le raisonnement du modèle (reasoning_content), au format entraîné : " + + "les étapes d'une boucle d'outils se relisent avec leur réflexion au lieu de blocs vides, et le moteur ne recalcule " + + "plus le dernier message ; plus de contexte par tour, compaction plus tôt. Vide/off (défaut) = rien n'est renvoyé. " + + "Jamais vers une API externe ni vers un autre modèle ; suspendu de lui-même si le gabarit le refuse"}, {"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"}, {"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"}, {"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},