Raisonnement : NUDGE_IN_TOOL décide sur le verdict de la forme de la requête

La sonde de gabarit gardait le verdict de la DERNIÈRE forme sondée (outils,
chat_template_kwargs, reasoning_effort), quelle qu'elle soit. NUDGE_IN_TOOL
pouvait donc glisser le rappel de budget dans un résultat d'outil sur la foi
d'un « préfixe instable » conclu pour une autre forme — un autre jeu
d'outils, la réflexion coupée, un autre niveau — dont le rendu n'a rien à
voir.

- Les verdicts sont aussi rangés par forme (empreinte tplShape, au plus 16,
  la plus ancienne cède) ; tplCapsFor rend celui de la forme demandée.
- NUDGE_IN_TOOL calcule la forme de la requête courante (mêmes outils,
  kwargs et niveau que ceux envoyés) et ne place le rappel dans le résultat
  que si CETTE forme est « instable » pour ce modèle ; forme jamais sondée ou
  inconnue : message à part, comme sans la clé. Une relance outils coupés ou
  tool_choice « none » (forme que la sonde ne voit jamais) se replie aussi.
- L'affichage (/api/perf/summary) et REASONING_ECHO gardent le dernier
  verdict, comme avant. Sans la clé, rien ne change.
- Tests : verdict par forme dans la sonde, autre forme / autres kwargs /
  autre niveau refusés, bout à bout avec le verdict d'une autre forme
  (échouait sur l'ancien code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 14:50:15 +02:00
1 parent ac40a2556a
commit 4b659ac186
6 files changed
+154 -20

No files matched your search

+68 -7
View File
@@ -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) {