Raisonnement : REASONING_ECHO renvoie au moteur local la réflexion du modèle, en opt-in

Les gabarits Qwen3.5/3.6 rendent <think>…</think> 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
  « </think> » 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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 08:26:04 +02:00
1 parent 3af481ff05
commit 7c9aa04fd9
12 files changed
+1121 -37

No files matched your search

+13 -6
View File
@@ -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 == "" {
+5 -1
View File
@@ -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))
}
}
}
+32 -1
View File
@@ -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
+5 -1
View File
@@ -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.
+21 -10
View File
@@ -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, " ; ")
+22 -5
View File
@@ -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("<think>" + r + "</think>")
}
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)
+34 -3
View File
@@ -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:
+105 -8
View File
@@ -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 « </think> » : 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
}
}
+268
View File
@@ -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
// `<think>…</think>` 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 « </think> » 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
}
+596
View File
@@ -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 « </think> » 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("</think>") + sseChunk("je regarde") + sseGlobStep
}
return 200, sseChunk("pensée</think>") + 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 pense</think>je 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]))
}
}
+8 -2
View File
@@ -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"},