diff --git a/README.md b/README.md index a25cd70..4b23ee9 100644 --- a/README.md +++ b/README.md @@ -473,8 +473,10 @@ Ajoutées par ce fork : et tout le tour est rendu autrement), ce rappel fait recalculer toute la boucle d'outils du tour. Avec `on`, le rappel part au bout du dernier résultat d'outil, et seul ce bout est à calculer — sur le moteur local, et seulement - si la sonde de gabarit a conclu que le rendu de ce modèle bouge (« inconnu » : - message à part, comme sans la clé). Le texte du rappel ne change pas, sa + si la sonde de gabarit a conclu que le rendu de ce modèle bouge pour la forme + de cette requête — mêmes outils, `chat_template_kwargs` et niveau de + raisonnement (« inconnu » ou forme jamais sondée : message à part, comme + sans la clé). Le texte du rappel ne change pas, sa place si : un modèle entraîné à se méfier des consignes lues dans une sortie d'outil peut moins bien le suivre. À comparer (tours qui concluent après le rappel) avant de l'adopter. diff --git a/internal/loki/chat_keep_images_test.go b/internal/loki/chat_keep_images_test.go index b18ebc1..e41af06 100644 --- a/internal/loki/chat_keep_images_test.go +++ b/internal/loki/chat_keep_images_test.go @@ -407,36 +407,65 @@ func TestKeptImagesDebutDeTour(t *testing.T) { } // NUDGE_IN_TOOL : seulement avec la clé, sur le moteur local, si la sonde a dit -// « instable » pour CE modèle. +// « instable » pour CE modèle et pour la forme de CETTE requête. func TestNudgeInToolOn(t *testing.T) { freshTplProbe(t) cfg := map[string]string{"MODEL": "/models/qwen35.gguf"} + tools := []Tool{readTool(), grepTool()} + kwargs := map[string]any{"enable_thinking": true} set := func(r tplProbeResult) { tplProbeMu.Lock() tplProbeLast = &r + tplProbeKeepShape(newTplShape(tools, kwargs, "").hash(), r) tplProbeMu.Unlock() } + on := func(cfg map[string]string, ep chatEndpoint) bool { return nudgeInToolOn(cfg, ep, tools, kwargs, "") } set(tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}) - if nudgeInToolOn(cfg, chatEndpoint{}) { + if on(cfg, chatEndpoint{}) { t.Fatal("actif sans la clé") } cfg["NUDGE_IN_TOOL"] = "on" - if !nudgeInToolOn(cfg, chatEndpoint{}) { + if !on(cfg, chatEndpoint{}) { t.Fatal("inactif avec la clé sur un gabarit instable") } - if nudgeInToolOn(cfg, chatEndpoint{External: true}) { + if on(cfg, chatEndpoint{External: true}) { t.Fatal("actif sur un preset externe") } + // Le verdict « instable » d'une forme ne vaut pas pour une autre : autres + // outils, autres kwargs ou autre niveau de raisonnement — inconnu, repli. + for name, other := range map[string]func() bool{ + "autres outils": func() bool { return nudgeInToolOn(cfg, chatEndpoint{}, []Tool{readTool()}, kwargs, "") }, + "autres kwargs": func() bool { + return nudgeInToolOn(cfg, chatEndpoint{}, tools, map[string]any{"enable_thinking": false}, "") + }, + "autre effort": func() bool { return nudgeInToolOn(cfg, chatEndpoint{}, tools, kwargs, "high") }, + } { + if other() { + t.Errorf("%s : actif sur le verdict d'une autre forme", name) + } + } for _, r := range []tplProbeResult{ {Model: "qwen35.gguf", PrefixStable: tplUnknown}, {Model: "qwen35.gguf", PrefixStable: tplYes}, {Model: "autre.gguf", PrefixStable: tplNo}, } { set(r) - if nudgeInToolOn(cfg, chatEndpoint{}) { + if on(cfg, chatEndpoint{}) { t.Fatalf("actif pour %+v", r) } } + // La dernière forme sondée (tplProbeLast) ne décide plus : une autre forme + // sondée « stable » ensuite ne change rien au verdict de la nôtre, et + // inversement. + set(tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}) + tplProbeMu.Lock() + last := tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplYes} + tplProbeLast = &last + tplProbeKeepShape(newTplShape(nil, kwargs, "").hash(), last) + tplProbeMu.Unlock() + if !on(cfg, chatEndpoint{}) { + t.Error("verdict de notre forme écrasé par la dernière forme sondée") + } } func TestNudgeIntoTool(t *testing.T) { @@ -468,11 +497,17 @@ func TestRappelDeBudgetDansLeResultat(t *testing.T) { t.Fatal(err) } } + // Le verdict de la sonde pour la forme exacte de la requête du tour : mêmes + // outils, mêmes chat_template_kwargs, même niveau de raisonnement. + caps := Caps{Agent: true} + _, effort, _, kwargs := turnReasoning(ReadConfig()) tplProbeMu.Lock() - tplProbeLast = &tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo} + r := tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo} + tplProbeLast = &r + tplProbeKeepShape(newTplShape(EnabledTools(caps), kwargs, effort).hash(), r) tplProbeMu.Unlock() reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop) - extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }) + extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, caps, func(StreamEvent) bool { return true }) if err != nil { t.Fatal(err) } @@ -486,6 +521,32 @@ func TestRappelDeBudgetDansLeResultat(t *testing.T) { } } +// Le verdict « instable » d'une AUTRE forme (dernière sondée, ici sans +// outils) ne décide pas pour ce tour : rappel à part, comme sans la clé. +func TestRappelDeBudgetVerdictDUneAutreForme(t *testing.T) { + withWorkspace(t) + freshTplProbe(t) + for k, v := range map[string]string{"AGENT_BUDGET": "1", "NUDGE_IN_TOOL": "on", "MODEL": "/models/qwen35.gguf"} { + if err := SetConfigKey(k, v); err != nil { + t.Fatal(err) + } + } + _, effort, _, kwargs := turnReasoning(ReadConfig()) + tplProbeMu.Lock() + r := tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo} + tplProbeLast = &r + tplProbeKeepShape(newTplShape(nil, kwargs, effort).hash(), r) + tplProbeMu.Unlock() + reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop) + if _, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + sent := reqMessages(t, reqs()[1]) + if last := sent[len(sent)-1]; last.Role != "user" { + t.Fatalf("rappel glissé dans le résultat sur le verdict d'une autre forme : %+v", last) + } +} + // En cours de tour, au seuil : les images gardées partent d'abord, et le // résumé n'est pas demandé si leur retrait suffit. func TestKeepImagesRetireesEnCoursDeTour(t *testing.T) { diff --git a/internal/loki/chat_tplprobe.go b/internal/loki/chat_tplprobe.go index 805a47a..9cd98ee 100644 --- a/internal/loki/chat_tplprobe.go +++ b/internal/loki/chat_tplprobe.go @@ -165,9 +165,14 @@ var ( // Attente entre deux essais sur un 503 (modèle en chargement). tplProbeBackoff = time.Second - tplProbeMu sync.Mutex - tplProbeCache = map[string]tplProbeResult{} - tplProbeLast *tplProbeResult + tplProbeMu sync.Mutex + tplProbeCache = map[string]tplProbeResult{} + tplProbeLast *tplProbeResult + // tplProbeByShape : le dernier verdict de chaque forme de requête + // (tplShape.hash). tplProbeLast est celui de la dernière forme sondée, + // quelle qu'elle soit : bon pour l'affichage, pas pour décider d'une + // requête d'une autre forme (tplCapsFor). + tplProbeByShape = map[string]tplProbeResult{} tplProbeRunning bool tplProbeTried time.Time tplProbeLogged string @@ -215,6 +220,20 @@ func tplCapsCurrent() (tplProbeResult, bool) { return *tplProbeLast, true } +// tplCapsFor : le dernier verdict pour CETTE forme de requête (mêmes outils, +// mêmes chat_template_kwargs, même reasoning_effort). Pas de verdict pour elle +// — jamais sondée, ou sortie du cache — : rien, et l'appelant se replie comme +// sur « inconnu ». Rien non plus pour un preset externe. +func tplCapsFor(shape tplShape) (tplProbeResult, bool) { + if externalActive() { + return tplProbeResult{}, false + } + tplProbeMu.Lock() + defer tplProbeMu.Unlock() + r, ok := tplProbeByShape[shape.hash()] + return r, ok +} + // tplProbeEnsure : verdict pour la forme donnée, depuis le cache ou une sonde. func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult { if resolveChatEndpoint().External { @@ -232,7 +251,7 @@ func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult { props, err := p.props(ctx) if err != nil { r := tplUnknownResult("/props illisible : " + err.Error()) - tplProbeStore("", r) + tplProbeStore("", shape.hash(), r) return r } h := fnv.New64a() @@ -245,6 +264,7 @@ func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult { tplProbeMu.Lock() if r, ok := tplProbeCache[key]; ok { tplProbeLast = &r + tplProbeKeepShape(shape.hash(), r) tplProbeMu.Unlock() return r } @@ -265,14 +285,30 @@ func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult { if fresh { fmt.Fprintln(os.Stderr, line) } - tplProbeStore(key, r) + tplProbeStore(key, shape.hash(), r) return r } -func tplProbeStore(key string, r tplProbeResult) { +// tplProbeKeepShape note le verdict d'une forme (tplProbeMu tenu). Au plus +// tplProbeCacheMax formes : la plus ancienne cède sa place. +func tplProbeKeepShape(shape string, r tplProbeResult) { + if _, ok := tplProbeByShape[shape]; !ok && len(tplProbeByShape) >= tplProbeCacheMax { + oldest := "" + for k, v := range tplProbeByShape { + if oldest == "" || v.At.Before(tplProbeByShape[oldest].At) { + oldest = k + } + } + delete(tplProbeByShape, oldest) + } + tplProbeByShape[shape] = r +} + +func tplProbeStore(key, shape string, r tplProbeResult) { tplProbeMu.Lock() defer tplProbeMu.Unlock() tplProbeLast = &r + tplProbeKeepShape(shape, r) if key == "" || !r.cacheable { return } diff --git a/internal/loki/chat_tplprobe_test.go b/internal/loki/chat_tplprobe_test.go index 59714c4..ae34c26 100644 --- a/internal/loki/chat_tplprobe_test.go +++ b/internal/loki/chat_tplprobe_test.go @@ -29,6 +29,7 @@ func freshTplProbe(t *testing.T) { tplProbeMu.Lock() tplProbeCache = map[string]tplProbeResult{} tplProbeLast = nil + tplProbeByShape = map[string]tplProbeResult{} tplProbeRunning = false tplProbeTried = time.Time{} tplProbeLogged = "" @@ -447,3 +448,33 @@ func TestSondeGabaritDeclencheeParRunChat(t *testing.T) { } } } + +// Le verdict est rangé par forme de requête : la dernière forme sondée ne +// décide pas pour une autre (NUDGE_IN_TOOL), et une forme jamais sondée n'a +// pas de verdict. +func TestSondeGabaritVerdictParForme(t *testing.T) { + testHome(t) + freshTplProbe(t) + f := &fakeTpl{mode: "qwen3"} + f.start(t) + a := newTplShape(probeTools(), nil, "") + b := newTplShape(nil, nil, "") + if r := tplProbeEnsure(context.Background(), a); r.PrefixStable != tplNo { + t.Fatalf("forme A : %+v", r) + } + f.mu.Lock() + f.mode = "keep" + f.mu.Unlock() + if r := tplProbeEnsure(context.Background(), b); r.PrefixStable != tplYes { + t.Fatalf("forme B : %+v", r) + } + if r, ok := tplCapsCurrent(); !ok || r.PrefixStable != tplYes { + t.Errorf("affichage : la dernière sondée, B : %+v", r) + } + if r, ok := tplCapsFor(a); !ok || r.PrefixStable != tplNo { + t.Errorf("forme A écrasée par B : %+v %v", r, ok) + } + if _, ok := tplCapsFor(newTplShape(probeTools(), map[string]any{"enable_thinking": false}, "")); ok { + t.Error("verdict pour une forme jamais sondée") + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 00038b8..db4ceae 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1417,8 +1417,10 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools // NUDGE_IN_TOOL (opt-in, gabarits dont le rendu bouge quand un message // user s'ajoute) : au bout du dernier résultat d'outil. Sinon, ou si // la forme ne s'y prête pas, un message à part comme toujours. + // La sonde ne connaît que la forme normale (outils annoncés, ni coupés + // ni neutralisés) : une relance d'une autre forme se replie. placed := false - if nudgeInToolOn(chatCfg, ep) { + if !disableTools && !toolChoiceNone && nudgeInToolOn(chatCfg, ep, tools, reasoningKwargs, reasoningEffort) { messages, extra, placed = nudgeIntoTool(messages, extra, msg) } if !placed { diff --git a/internal/loki/llm_injected.go b/internal/loki/llm_injected.go index 1866e86..78ab5d9 100644 --- a/internal/loki/llm_injected.go +++ b/internal/loki/llm_injected.go @@ -174,9 +174,11 @@ func withTrailingHint(msgs []Message, hint string) []Message { // l'opt-in, à n'activer qu'après avoir comparé l'obéissance aux rappels. // // Moteur local seulement, et seulement si la sonde de gabarit -// (chat_tplprobe.go) a conclu « préfixe instable » pour CE modèle : « inconnu » -// ou un autre modèle laissent le message à part. -func nudgeInToolOn(cfg map[string]string, ep chatEndpoint) bool { +// (chat_tplprobe.go) a conclu « préfixe instable » pour CE modèle et pour la +// forme de CETTE requête (outils, chat_template_kwargs, reasoning_effort : le +// rendu en dépend). « inconnu », un autre modèle ou une forme jamais sondée +// laissent le message à part. +func nudgeInToolOn(cfg map[string]string, ep chatEndpoint, tools []Tool, kwargs map[string]any, effort string) bool { switch strings.ToLower(strings.TrimSpace(cfg["NUDGE_IN_TOOL"])) { case "on", "1", "true", "yes", "oui": default: @@ -185,7 +187,7 @@ func nudgeInToolOn(cfg map[string]string, ep chatEndpoint) bool { if ep.External { return false } - r, ok := tplCapsCurrent() + r, ok := tplCapsFor(newTplShape(tools, kwargs, effort)) return ok && r.PrefixStable == tplNo && r.Model != "" && r.Model == reasoningEchoModel(cfg) }