mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
3af481ff05
commit
7c9aa04fd9
12 files changed
+1121
-37
No files matched your search
@@ -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 == "" {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, " ; ")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user