Lot 23 : sonde de gabarit en Rust (avec LOKI_CHAT=rust)

Port de chat_tplprobe.go : la façade sonde le gabarit du moteur après ses
complétions (/health, /props, /apply-template), avec les verdicts
preserves_history, prefix_stable et renders_reasoning, le cache par
build/modèle/gabarit/forme et la relance bornée.

- l'écho du raisonnement suit le verdict de la sonde (echo_policy_for) ;
- NUDGE_IN_TOOL ne garde plus la conversation au Go : le rappel de budget
  va dans le dernier résultat d'outil quand le préfixe n'est pas stable ;
- le Go lit le verdict de la façade (/internal/facade/v1/chat/tpl) quand
  elle tient la conversation.

Parité : parity_tplprobe_test.go tire du Go un journal d'échanges rejoué
octet pour octet par tests/tplprobe.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 21:34:57 +00:00
1 parent 78766a28d9
commit 0f3a3d52c1
17 files changed
+5006 -22

No files matched your search

+1 -1
View File
@@ -227,7 +227,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris, avec l'export, le mode Code et les critères de la discussion. Le Go garde les autres outils et le reste du mode Code. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go |
| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris, avec l'export, le mode Code et les critères de la discussion. Le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`). Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go |
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
@@ -0,0 +1,85 @@
# Lot 23 — sonde de gabarit portée en Rust
## Contexte
La sonde de gabarit (`chat_tplprobe.go`) dit ce que le gabarit de chat du
modèle chargé fait de ce qu'on lui envoie :
- `preserves_history` : le raisonnement d'un tour passé est-il encore rendu ?
- `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
préfixe exact du suivant ?
- `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
Le Go la lance après chaque complétion du fil principal, avec les outils et
les `chat_template_kwargs` de la requête. Trois choses lisent son verdict :
- la politique d'écho du raisonnement (`REASONING_ECHO`) ;
- le rappel de budget glissé dans le résultat d'outil (`NUDGE_IN_TOOL`) ;
- le résumé des performances (`/api/perf/summary`).
Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
servent le panneau Moteur, autour d'une mise à jour.
Quand la façade tenait la conversation (`LOKI_CHAT=rust`), plus aucune sonde
ne tournait :
- l'écho décidait sans verdict ;
- `NUDGE_IN_TOOL` gardait la conversation au Go (`facadeChatBlocker`).
## Le lot
`rust/loki-rs/src/chat/tplprobe.rs` porte la sonde entière :
- les requêtes au moteur local (`/health`, `/props`, `/apply-template`, avec la
clé du moteur et les relances sur 503) ;
- les rendus synthétiques et leurs verdicts ;
- le cache par build, modèle, gabarit et forme ;
- le dernier verdict, global et par forme ;
- la ligne de journal ;
- le déclenchement borné (une sonde en vol au plus, une par minute) ;
- le relevé du rendu et sa comparaison.
Avec `LOKI_CHAT=rust` :
- **la boucle du tour** signale chaque complétion lue en entier sur le moteur
local, outils ni coupés ni neutralisés (`TurnHost::completed`). L'hôte lance
la sonde pour le fil principal seulement : ni tâche ni sous-agent ;
- **la politique d'écho** lit le dernier verdict ;
- **`NUDGE_IN_TOOL`** est porté (`nudge_in_tool_key`, `nudge_into_tool`,
`TurnHost::prefix_unstable`). Il ne garde plus la conversation au Go ;
- **le résumé des performances**, que sert encore le Go, relit le verdict de la
façade (`/internal/facade/v1/chat/tpl`, `facadeTplCaps`).
## Parité
`internal/loki/parity_tplprobe_test.go` enregistre chaque échange de la sonde
du Go avec un faux moteur : méthode, chemin, clé, corps envoyé, réponse.
Les comportements de moteur couverts :
- gabarit Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce
ignorée ;
- 404, exception du gabarit, clé exigée (inconnue ou connue) ;
- 503 relancés ou persistants, 429 ;
- `/props` illisible, moteur pas prêt, réponse sans prompt ;
- rendus incohérents, preset externe.
Le journal contient aussi :
- le verdict, sa ligne de journal, `tplCapsCurrent` et `tplCapsFor` ;
- le cache : même moteur, gabarit changé, build changé ;
- le relevé du rendu sur trois gabarits, et une erreur ;
- neuf comparaisons de relevés ;
- l'empreinte de six formes.
`tests/tplprobe.rs` rejoue chaque échange contre le port. Le faux moteur rend
les réponses enregistrées et exige des requêtes identiques, octet pour octet.
Il vérifie aussi le déclenchement borné.
Autres tests :
- `tests/lot6.rs` vérifie la sonde lancée après un vrai tour de la façade ;
- les tests de la boucle vérifient le rappel dans le résultat d'outil et les
complétions signalées ;
- `TestFacadeSondeDeGabarit` vérifie, côté Go, que le résumé des performances
relit le verdict de la façade.
+3
View File
@@ -238,6 +238,9 @@ func tplCapsCurrent() (tplProbeResult, bool) {
if externalActive() {
return tplProbeResult{}, false
}
if facadeChat() { // la façade sonde après SES complétions (facade_chat.go)
return facadeTplCaps()
}
tplProbeMu.Lock()
defer tplProbeMu.Unlock()
if tplProbeLast == nil {
+27 -12
View File
@@ -49,15 +49,14 @@ func facadeChat() bool {
// facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y
// en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet
// figé, images gardées, slots du moteur, préchauffage, motifs de relance
// personnalisés).
// personnalisés). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa sonde
// de gabarit.
func facadeChatBlocker(cfg map[string]string) string {
switch {
case projSnapEnabled(cfg):
return "PROJ_SNAPSHOT"
case keepTurnImagesOn(cfg):
return "KEEP_TURN_IMAGES"
case cfgOn(cfg, "NUDGE_IN_TOOL"):
return "NUDGE_IN_TOOL"
case sideSlotOn(cfg):
return "SIDE_SLOT"
case slotPersistOn(cfg):
@@ -70,15 +69,6 @@ func facadeChatBlocker(cfg map[string]string) string {
return ""
}
// cfgOn : une clé booléenne posée (mêmes valeurs que les autres clés opt-in).
func cfgOn(cfg map[string]string, k string) bool {
switch strings.ToLower(strings.TrimSpace(cfg[k])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// Décision prise UNE fois par process : passer la conversation d'un détenteur
// à l'autre en cours de route laisserait chacun avec sa copie — un réglage
// changé vaut au redémarrage suivant. La façade la lit au démarrage
@@ -244,6 +234,31 @@ func facadePost(path string, body any) error {
return nil
}
// facadeTplCaps : le dernier verdict de la sonde de gabarit de la façade,
// quand elle tient la conversation (c'est elle qui sonde après ses
// complétions).
func facadeTplCaps() (tplProbeResult, bool) {
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/chat/tpl", strings.NewReader("{}"))
if err != nil {
return tplProbeResult{}, false
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeClient.Do(req)
if err != nil {
return tplProbeResult{}, false
}
defer resp.Body.Close()
var out struct {
OK bool `json:"ok"`
Result tplProbeResult `json:"result"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil || !out.OK {
return tplProbeResult{}, false
}
return out.Result, true
}
// facadeError : refus de la façade, avec son statut (409 : un tour tourne).
type facadeError struct {
status int
+39 -1
View File
@@ -298,7 +298,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu())
}
for k, want := range map[string]string{
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "NUDGE_IN_TOOL", "SIDE_SLOT": "SIDE_SLOT",
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "SIDE_SLOT",
"SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM",
} {
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
@@ -1064,3 +1064,41 @@ func TestFacadeBackends(t *testing.T) {
}
lcAppend("perdue") // tracée, sans panique
}
// Lot 23 : quand la façade tient la conversation, c'est elle qui sonde le
// gabarit ; le résumé des performances relit son verdict.
func TestFacadeSondeDeGabarit(t *testing.T) {
testHome(t)
freshTplProbe(t)
oublierDecisionFacade(t)
verdict := `{"ok":true,"result":{"preserves_history":"no","prefix_stable":"no","renders_reasoning":"yes","model":"m1.gguf","tools":2,"at":"2026-10-15T20:00:00.5Z"}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest || r.URL.Path != "/internal/facade/v1/chat/tpl" {
w.WriteHeader(404)
return
}
io.WriteString(w, verdict)
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
t.Setenv(facadeChatEnv, "rust")
r, ok := tplCapsCurrent()
if !ok || r.PrefixStable != tplNo || r.Model != "m1.gguf" || r.Tools != 2 || r.At.IsZero() {
t.Fatalf("verdict de la façade : %+v %v", r, ok)
}
rec := httptest.NewRecorder()
handlePerfSummary(rec, httptest.NewRequest(http.MethodGet, "/api/perf/summary", nil))
if !strings.Contains(rec.Body.String(), `"prefix_stable":"no"`) {
t.Fatalf("résumé : %s", rec.Body.String())
}
// Aucun verdict encore, ou façade injoignable : rien.
verdict = `{"ok":false}`
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict inventé")
}
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict sans façade")
}
}
+358
View File
@@ -0,0 +1,358 @@
package loki
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// parity_tplprobe_test.go — journal de référence de la sonde de gabarit
// (lot 23 de la migration Rust) : pour chaque comportement de moteur (gabarit
// Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce ignorée,
// 404, exception du gabarit, clé exigée, 503 relancés ou persistants, /props
// illisible, moteur pas prêt, réponse sans prompt, rendu incohérent, preset
// externe), chaque échange avec le moteur — méthode, chemin, clé, corps envoyé,
// réponse — puis le verdict, sa ligne de journal, ce qu'en disent
// tplCapsCurrent et tplCapsFor, et le cache (même moteur : pas de nouveau
// rendu ; gabarit changé : nouvelle sonde). Puis le relevé du rendu
// (tplRenderPrint), sa comparaison (tplRenderDiff) et l'empreinte des formes.
// Rangé dans rust/loki-rs/tests/parity_tplprobe/tplprobe.json ;
// tests/tplprobe.rs rejoue chaque échange contre le port Rust.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSondeGabarit
const parityTplFile = "../../rust/loki-rs/tests/parity_tplprobe/tplprobe.json"
// Un échange avec le moteur, tel que le moteur l'a vu.
type parityTplExchange struct {
Method string `json:"method"`
Path string `json:"path"`
Auth string `json:"auth,omitempty"`
Body string `json:"body,omitempty"`
Status int `json:"status"`
Reply string `json:"reply"`
}
// parityTplEngine : le faux moteur des tests de la sonde (fakeTpl), avec des
// réponses imposées par chemin et le journal des échanges.
type parityTplEngine struct {
fake *fakeTpl
mu sync.Mutex
override map[string][]parityTplExchange // chemin → réponses imposées, consommées dans l'ordre
exchanges []parityTplExchange
}
func (e *parityTplEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
e.mu.Lock()
var forced *parityTplExchange
if l := e.override[r.URL.Path]; len(l) > 0 {
forced = &l[0]
e.override[r.URL.Path] = l[1:]
}
e.mu.Unlock()
rec := httptest.NewRecorder()
if forced != nil {
rec.WriteHeader(forced.Status)
_, _ = rec.WriteString(forced.Reply)
} else {
r2 := r.Clone(r.Context())
r2.Body = io.NopCloser(strings.NewReader(string(body)))
e.fake.handler(rec, r2)
}
ex := parityTplExchange{Method: r.Method, Path: r.URL.Path, Auth: r.Header.Get("Authorization"),
Body: string(body), Status: rec.Code, Reply: rec.Body.String()}
e.mu.Lock()
e.exchanges = append(e.exchanges, ex)
e.mu.Unlock()
w.WriteHeader(rec.Code)
_, _ = w.Write(rec.Body.Bytes())
}
// parityTplResult : le verdict sans l'instant, plus ce qui n'est pas exporté.
func parityTplResult(r tplProbeResult) map[string]any {
b, _ := json.Marshal(r)
var m map[string]any
_ = json.Unmarshal(b, &m)
delete(m, "at")
m["cacheable"] = r.cacheable
return m
}
func TestPariteSondeGabarit(t *testing.T) {
type step struct {
Shape string `json:"shape"` // forme rejouée (shapes)
Mode string `json:"mode,omitempty"`
Template string `json:"template,omitempty"`
Build string `json:"build,omitempty"`
Override map[string][]parityTplExchange `json:"override,omitempty"`
Exchanges []parityTplExchange `json:"exchanges"`
Result any `json:"result"`
Line string `json:"line"`
Current any `json:"current"`
For any `json:"for"`
}
type scenario struct {
Name string `json:"name"`
Key string `json:"key,omitempty"` // clé exigée par le moteur
APIKey string `json:"api_key,omitempty"` // clé connue de Loki (state/api_key)
External bool `json:"external,omitempty"` // preset externe actif
Config map[string]string `json:"config,omitempty"`
Steps []step `json:"steps"`
}
shapes := map[string]struct {
Tools string `json:"tools"`
Kwargs map[string]any `json:"kwargs"`
Effort string `json:"effort"`
}{
"nue": {},
"outils": {Tools: `[{"type":"function","function":{"name":"bash","description":"d <b> & é","parameters":{"type":"object"}}}]`, Kwargs: map[string]any{"enable_thinking": false}},
"kwargs": {Kwargs: map[string]any{"enable_thinking": true, "reasoning_effort": "high"}},
"effort": {Effort: "low"},
"deux": {Tools: `[{"type":"function","function":{"name":"lire","description":"","parameters":{"type":"object","properties":{"p":{"type":"string"}}}}},{"type":"function","function":{"name":"écrire","description":"x","parameters":null}}]`},
"kw-vides": {Kwargs: map[string]any{}},
}
shapeOf := func(name string) tplShape {
s := shapes[name]
var tools []Tool
if s.Tools != "" {
if err := json.Unmarshal([]byte(s.Tools), &tools); err != nil {
t.Fatal(err)
}
}
return newTplShape(tools, s.Kwargs, s.Effort)
}
jsonErr := `{"error":{"code":500,"message":"Jinja Exception: ` + strings.Repeat("é", 200) + `"}}`
scenarios := []scenario{
{Name: "Qwen3, outils et kwargs", Steps: []step{
{Shape: "outils", Mode: "qwen3"},
{Shape: "outils", Mode: "qwen3"}, // en cache : /health et /props seulement
{Shape: "nue", Mode: "qwen3"}, // autre forme : nouvelle sonde
{Shape: "outils", Mode: "qwen3", Template: "qwen3 v2"},
{Shape: "outils", Mode: "qwen3", Template: "qwen3 v2", Build: "b8000-def"},
}},
{Name: "garde tout", Steps: []step{{Shape: "nue", Mode: "keep"}, {Shape: "deux", Mode: "keep"}}},
{Name: "raisonnement jamais rendu", Steps: []step{{Shape: "kwargs", Mode: "drop"}}},
{Name: "amorce ignorée", Steps: []step{{Shape: "effort", Mode: "noagp"}}},
{Name: "route absente", Steps: []step{
{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 404, Reply: "Not Found"}, {Status: 404, Reply: ""}, {Status: 404, Reply: ""}, {Status: 404, Reply: ""},
}}},
{Shape: "nue", Mode: "keep"}, // 404 définitif : en cache
}},
{Name: "exception du gabarit", Steps: []step{{Shape: "nue", Mode: "keep",
Override: map[string][]parityTplExchange{"/apply-template": {{Status: 500, Reply: jsonErr}}}}}},
{Name: "clé exigée, inconnue", Key: "sk-loki-k", Steps: []step{{Shape: "nue", Mode: "keep"}, {Shape: "nue", Mode: "keep"}}},
{Name: "clé exigée, connue", Key: "sk-loki-k", APIKey: "sk-loki-k", Steps: []step{{Shape: "nue", Mode: "keep"}}},
{Name: "503 puis rendu", Steps: []step{{Shape: "nue", Mode: "qwen3", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 503, Reply: `{"error":{"code":503,"message":"Loading model"}}`}, {Status: 503, Reply: `{"error":{"code":503,"message":"Loading model"}}`},
}}}}},
{Name: "503 persistant", Steps: []step{{Shape: "nue", Mode: "qwen3", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 503, Reply: "x"}, {Status: 503, Reply: "x"}, {Status: 503, Reply: "x"},
{Status: 503, Reply: "y"}, {Status: 503, Reply: "y"}, {Status: 503, Reply: "y"},
}}}, {Shape: "nue", Mode: "qwen3"}}}, // non rangé : resondé
{Name: "messages aux bornes", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 500, Reply: " " + strings.Repeat("à", 160) + " "}, {Status: 500, Reply: strings.Repeat("ù", 161)},
}}}}},
{Name: "props de travers", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/props": {{Status: 200, Reply: `{"build_info":5}`}}}}}},
{Name: "historique sans la question B", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{"prompt":"` + tplMarkC1 + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` amorce"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` ` + tplMarkB + `"}`},
}}}}},
{Name: "préfixe sans la question B", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{"prompt":"` + tplMarkC1 + ` ` + tplMarkB + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` amorce"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` suite"}`},
}}}}},
{Name: "historique en erreur, outil rendu", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 500, Reply: "boum"},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + `"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` amorce"}`},
{Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` ` + tplMarkB + `"}`},
}}}}},
{Name: "trop de requêtes", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 429, Reply: " lent "},
}}}}},
{Name: "props illisible", Steps: []step{
{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/props": {{Status: 200, Reply: "<html>"}}}},
{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/props": {{Status: 500, Reply: "panne"}}}},
}},
{Name: "moteur pas prêt", Steps: []step{{Shape: "outils", Mode: "keep", Override: map[string][]parityTplExchange{"/health": {{Status: 503, Reply: `{"status":"loading"}`}}}}}},
{Name: "réponse sans prompt", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{}`}, {Status: 200, Reply: `{"prompt":null}`}, {Status: 200, Reply: `pas du json`},
}}}}},
{Name: "rendu incohérent", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{"prompt":"x"}`}, {Status: 200, Reply: `{"prompt":"y"}`}, {Status: 200, Reply: `{"prompt":"z"}`}, {Status: 200, Reply: `{"prompt":"yz"}`},
}}}}},
{Name: "préfixe incohérent", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {
{Status: 200, Reply: `{"prompt":"x"}`}, {Status: 200, Reply: `{"prompt":"y"}`}, {Status: 200, Reply: `{"prompt":"z"}`}, {Status: 200, Reply: `{"prompt":"w"}`},
}}}}},
{Name: "preset externe", External: true, Steps: []step{{Shape: "nue", Mode: "keep"}}},
}
for i := range scenarios {
sc := &scenarios[i]
t.Run(sc.Name, func(t *testing.T) {
testHome(t)
freshTplProbe(t)
eng := &parityTplEngine{fake: &fakeTpl{key: sc.Key}, override: map[string][]parityTplExchange{}}
srv := httptest.NewServer(eng)
defer srv.Close()
u, _ := url.Parse(srv.URL)
if sc.External {
if err := WriteConfig(parseEnv(externalPresetContent("http://127.0.0.1:1", "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
sc.Config = ReadConfig()
}
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
if sc.APIKey != "" {
if err := writeAPIKey(sc.APIKey); err != nil {
t.Fatal(err)
}
}
for j := range sc.Steps {
st := &sc.Steps[j]
eng.mu.Lock()
eng.fake.mode, eng.fake.tpl, eng.fake.build = st.Mode, st.Template, st.Build
for p, l := range st.Override {
eng.override[p] = append([]parityTplExchange(nil), l...)
}
eng.exchanges = nil
eng.mu.Unlock()
r := tplProbeEnsure(context.Background(), shapeOf(st.Shape))
eng.mu.Lock()
st.Exchanges = append([]parityTplExchange(nil), eng.exchanges...)
eng.override = map[string][]parityTplExchange{}
eng.mu.Unlock()
st.Result = parityTplResult(r)
st.Line = tplProbeLine(r)
if c, ok := tplCapsCurrent(); ok {
st.Current = parityTplResult(c)
}
if c, ok := tplCapsFor(shapeOf(st.Shape)); ok {
st.For = parityTplResult(c)
}
}
if sc.External {
delete(sc.Config, "PORT")
}
})
}
// --- relevé du rendu et comparaison ---
type printCase struct {
Shape string `json:"shape"`
Mode string `json:"mode"`
Override map[string][]parityTplExchange `json:"override,omitempty"`
Exchanges []parityTplExchange `json:"exchanges"`
Out string `json:"out"`
Err string `json:"err"`
}
prints := []printCase{
{Shape: "nue", Mode: "qwen3"},
{Shape: "outils", Mode: "keep"},
{Shape: "kwargs", Mode: "drop"},
{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {{Status: 200, Reply: `{"prompt":"a"}`}, {Status: 500, Reply: "boum"}}}},
}
func() {
testHome(t)
freshTplProbe(t)
eng := &parityTplEngine{fake: &fakeTpl{}, override: map[string][]parityTplExchange{}}
srv := httptest.NewServer(eng)
defer srv.Close()
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
for i := range prints {
pc := &prints[i]
eng.mu.Lock()
eng.fake.mode = pc.Mode
for p, l := range pc.Override {
eng.override[p] = append([]parityTplExchange(nil), l...)
}
eng.exchanges = nil
eng.mu.Unlock()
out, err := tplRenderPrint(context.Background(), tplLocalProber(), shapeOf(pc.Shape))
pc.Out = out
if err != nil {
pc.Err = err.Error()
}
eng.mu.Lock()
pc.Exchanges = append([]parityTplExchange(nil), eng.exchanges...)
eng.override = map[string][]parityTplExchange{}
eng.mu.Unlock()
}
}()
type diffCase struct {
Before string `json:"before"`
After string `json:"after"`
Same bool `json:"same"`
Detail string `json:"detail"`
}
long := strings.Repeat("abcdéfgh\n", 30)
var diffs []diffCase
for _, c := range [][2]string{
{prints[0].Out, prints[0].Out},
{prints[0].Out, prints[1].Out},
{prints[1].Out, prints[2].Out},
{"", "x"},
{"x", ""},
{"abc", "abd"},
{long + "X" + long, long + "Y" + long},
{tplRenderSep + "section ---\n" + "ééé" + long, tplRenderSep + "section ---\n" + "éèé" + long},
{"sans\x00section", "sans\x00sectioN"},
} {
same, d := tplRenderDiff(c[0], c[1])
diffs = append(diffs, diffCase{Before: c[0], After: c[1], Same: same, Detail: d})
}
// --- empreinte des formes ---
hashes := map[string]string{}
for name := range shapes {
hashes[name] = shapeOf(name).hash()
}
got, err := parityJSON(map[string]any{
"shapes": shapes,
"scenarios": scenarios,
"prints": prints,
"diffs": diffs,
"hashes": hashes,
})
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityTplFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityTplFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityTplFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("la sonde de gabarit ne produit plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSondeGabarit,\n"+
"puis cargo test --test tplprobe dit si le port Rust suit.\n--- obtenu ---\n%s", parityTplFile, got)
}
}
+7 -1
View File
@@ -494,7 +494,12 @@ impl ChatService {
let cfg = store.config().await;
let local_key = store.get_str(BK_STATE, "api_key").await;
let ep = resolve_chat_endpoint(&cfg, &local_key);
let echo = echo_policy_for(&cfg, &self.shared.echo_refusals, None);
let verdict = self
.shared
.tpl
.caps_current(ep.external)
.map(|r| r.verdict());
let echo = echo_policy_for(&cfg, &self.shared.echo_refusals, verdict.as_ref());
let ccfg = CompactConfig::from_config(&cfg, echo.clone());
let port = llm_port(&cfg);
let summarizer = EngineSummarizer {
@@ -1062,6 +1067,7 @@ impl ChatShared {
compact_memory: super::compact::CompactMemory::new(),
images: super::host::ImageCache::new(img_dir),
patterns: super::stream::RetryPatterns::builtin(),
tpl: std::sync::Arc::new(super::tplprobe::TplProbe::new()),
}
}
}
+36 -1
View File
@@ -39,7 +39,7 @@ use super::wire::{
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME,
build_chat_payload, content_parts, part_image_url, wire_messages,
};
use crate::store::Store;
use crate::store::{BK_STATE, Store};
/// Taille au-delà de laquelle le cache d'images oublie les plus anciennes
/// (`imgCacheMax`).
@@ -284,6 +284,8 @@ pub struct ChatShared {
pub compact_memory: CompactMemory,
pub images: ImageCache,
pub patterns: Arc<RetryPatterns>,
/// Sonde de gabarit (`chat_tplprobe.go`) : derniers verdicts et cache.
pub tpl: Arc<super::tplprobe::TplProbe>,
}
/// Ce que la compaction sait du tour.
@@ -490,6 +492,39 @@ impl TurnHost for GoTurnHost {
(self.queued)()
}
fn prefix_unstable(
&self,
tools: &[Tool],
kwargs: Option<&Map<String, Value>>,
effort: &str,
) -> bool {
let shape = super::tplprobe::Shape::new(tools, kwargs, effort);
self.shared.tpl.caps_for(false, &shape).is_some_and(|r| {
r.prefix_stable == super::wire::TplTri::No
&& !r.model.is_empty()
&& r.model == super::wire::reasoning_echo_model(&self.cfg)
})
}
/// Fil principal seulement (`perfMain`) : une tâche ou un sous-agent ne
/// déclenche pas la sonde.
fn completed(&self, tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) {
if self.compact.main_conv.is_empty() {
return;
}
let shape = super::tplprobe::Shape::new(tools, kwargs, effort);
let (shared, port) = (self.shared.clone(), self.port);
tokio::spawn(async move {
let key = shared.store.get_str(BK_STATE, "api_key").await;
let env = super::tplprobe::Env {
external: false,
port: port.into(),
key,
};
shared.tpl.kick(env, shape);
});
}
async fn prepare_images(&self, msgs: &[Message]) {
self.shared.images.prefetch(&self.shared.store, msgs).await;
}
+3 -1
View File
@@ -17,7 +17,8 @@
//! - [`turn`] : la boucle d'un tour (complétions, outils, filets) ;
//! - [`host`] : le tour branché sur le moteur, les outils du Go et la base ;
//! - [`history`] : début et fin d'un tour côté historique ;
//! - [`generate`] : un tour de bout en bout (envoi, file, fin de tour).
//! - [`generate`] : un tour de bout en bout (envoi, file, fin de tour) ;
//! - [`tplprobe`] : sonde de gabarit (rendus de `/apply-template`).
pub mod compact;
pub mod conv;
@@ -31,6 +32,7 @@ pub mod nudges;
pub mod persist;
pub mod sessions;
pub mod stream;
pub mod tplprobe;
pub mod turn;
pub mod types;
pub mod verify;
+89
View File
@@ -75,6 +75,45 @@ pub fn append_nudge(messages: &mut Vec<Message>, extra: &mut Vec<Message>, text:
}
}
/// La clé `NUDGE_IN_TOOL` est-elle posée (`nudgeInToolOn`, sans la sonde) ?
pub fn nudge_in_tool_key(cfg: &std::collections::HashMap<String, String>) -> bool {
matches!(
cfg.get("NUDGE_IN_TOOL")
.map(|v| v.trim().to_lowercase())
.as_deref(),
Some("on" | "1" | "true" | "yes" | "oui")
)
}
/// `nudgeIntoTool` : le rappel au bout du dernier résultat d'outil, dans la
/// vue du modèle ET dans l'historique, à l'identique. `false` : dernier
/// message d'une autre forme, ou historique réécrit entre-temps — l'appelant
/// passe alors par [`append_nudge`].
pub fn nudge_into_tool(messages: &mut [Message], extra: &mut [Message], text: &str) -> bool {
let (Some(last), Some(kept)) = (messages.last(), extra.last()) else {
return false;
};
let (Some(Value::String(s)), Some(Value::String(ks))) = (&last.content, &kept.content) else {
return false;
};
if s != ks
|| last.role != "tool"
|| kept.role != "tool"
|| last.tool_call_id != kept.tool_call_id
{
return false;
}
let mut m = last.clone();
m.content = Some(Value::String(format!("{s}\n\n{text}")));
if let Some(l) = extra.last_mut() {
*l = m.clone();
}
if let Some(l) = messages.last_mut() {
*l = m;
}
true
}
/// `steerSystem` : la consigne s'ajoute au système de tête (texte), ou
/// devient un système en tête — jamais un système en fin de fil, que les
/// gabarits stricts refusent.
@@ -224,4 +263,54 @@ mod tests {
(TOOL_PREVIEW_CHARS, TOOL_PREVIEW_CHARS + 5, true)
);
}
#[test]
fn rappel_dans_le_resultat_d_outil() {
let tm = |c: &str| Message {
role: "tool".into(),
content: Some(Value::String(c.into())),
tool_call_id: "t1".into(),
..Default::default()
};
let um = |c: &str| Message {
role: "user".into(),
content: Some(Value::String(c.into())),
..Default::default()
};
let mut msgs = vec![um("q"), tm("res")];
let mut extra = vec![tm("res")];
assert!(nudge_into_tool(&mut msgs, &mut extra, "[system] stop"));
assert_eq!(
msgs[1].content,
Some(Value::String("res\n\n[system] stop".into()))
);
assert_eq!(msgs[1], extra[0]);
// Dernier message d'une autre forme, ou historique réécrit.
let mut m2 = vec![tm("res"), um("img")];
assert!(!nudge_into_tool(&mut m2, &mut [tm("res")], "x"));
assert!(!nudge_into_tool(&mut [tm("res")], &mut [], "x"));
assert!(!nudge_into_tool(&mut [tm("res")], &mut [tm("autre")], "x"));
let mut other_id = tm("res");
other_id.tool_call_id = "t2".into();
assert!(!nudge_into_tool(&mut [tm("res")], &mut [other_id], "x"));
let mut empty = tm("");
empty.content = None;
assert!(!nudge_into_tool(&mut [empty.clone()], &mut [empty], "x"));
}
#[test]
fn cle_nudge_in_tool() {
for (v, on) in [
("on", true),
(" TRUE ", true),
("oui", true),
("off", false),
("", false),
] {
let cfg =
std::collections::HashMap::from([("NUDGE_IN_TOOL".to_string(), v.to_string())]);
assert_eq!(nudge_in_tool_key(&cfg), on, "{v}");
}
assert!(!nudge_in_tool_key(&std::collections::HashMap::new()));
}
}
+915
View File
@@ -0,0 +1,915 @@
//! Sonde de gabarit (lot 23 de la migration) — port de `chat_tplprobe.go`.
//!
//! Que fait le gabarit de chat du modèle chargé de ce qu'on lui envoie ?
//! - `preserves_history` : le raisonnement d'un tour PASSÉ est-il rendu ?
//! - `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
//! préfixe exact du suivant ?
//! - `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
//!
//! Mêmes règles de conduite que le Go : moteur local seulement, rien d'autre
//! que `POST /apply-template` (sans état, sans slot), lancée après une
//! complétion terminée, avec les outils et `chat_template_kwargs` de cette
//! complétion ; tout échec donne « unknown ».
//!
//! Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
//! servent la vérification autour d'une mise à jour du moteur.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::Serialize;
use serde_json::value::RawValue;
use serde_json::{Map, Value, json};
use super::types::{Tool, ToolCall, ToolCallFunc};
use super::wire::TplTri;
use crate::gojson;
/// Délai de chaque requête (`tplProbeTimeout`).
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// Au plus une vérification par minute (`tplProbeEvery`).
pub const PROBE_EVERY: Duration = Duration::from_secs(60);
const CACHE_MAX: usize = 16;
const TRIES_503: u32 = 3;
const BODY_MAX: usize = 8 << 20;
const MARK_SYS: &str = "loki-sonde-systeme";
const MARK_A: &str = "loki-sonde-question-a";
const MARK_R1: &str = "loki-sonde-raisonnement-r1";
const MARK_C1: &str = "loki-sonde-reponse-c1";
const MARK_T1: &str = "loki-sonde-resultat-t1";
const MARK_B: &str = "loki-sonde-question-b";
const CALL_ID: &str = "a1B2c3D4e";
/// `tplRenderSep`.
pub const RENDER_SEP: &str = "\n\x00--- ";
impl TplTri {
pub fn as_str(self) -> &'static str {
match self {
TplTri::Yes => "yes",
TplTri::No => "no",
TplTri::Unknown => "unknown",
}
}
}
/// `tplProbeResult`.
#[derive(Debug, Clone, PartialEq)]
pub struct ProbeResult {
pub preserves_history: TplTri,
pub prefix_stable: TplTri,
pub renders_reasoning: TplTri,
pub build: String,
pub model: String,
pub template_hash: String,
pub tools: usize,
pub kwargs: Option<Map<String, Value>>,
pub effort: String,
pub note: String,
pub at: SystemTime,
/// La réponse tient tant que moteur, modèle et gabarit restent les mêmes.
pub cacheable: bool,
}
impl ProbeResult {
/// `tplUnknownResult`.
pub fn unknown(note: &str) -> Self {
ProbeResult {
preserves_history: TplTri::Unknown,
prefix_stable: TplTri::Unknown,
renders_reasoning: TplTri::Unknown,
build: String::new(),
model: String::new(),
template_hash: String::new(),
tools: 0,
kwargs: None,
effort: String::new(),
note: note.into(),
at: SystemTime::now(),
cacheable: false,
}
}
/// Le JSON du Go (champs dans l'ordre de la struct, `omitempty` compris).
pub fn to_json(&self) -> Value {
let mut m = Map::new();
m.insert(
"preserves_history".into(),
json!(self.preserves_history.as_str()),
);
m.insert("prefix_stable".into(), json!(self.prefix_stable.as_str()));
m.insert(
"renders_reasoning".into(),
json!(self.renders_reasoning.as_str()),
);
for (k, v) in [
("build", &self.build),
("model", &self.model),
("template_hash", &self.template_hash),
] {
if !v.is_empty() {
m.insert(k.into(), json!(v));
}
}
m.insert("tools".into(), json!(self.tools));
if let Some(k) = self.kwargs.as_ref().filter(|k| !k.is_empty()) {
m.insert("chat_template_kwargs".into(), Value::Object(k.clone()));
}
if !self.effort.is_empty() {
m.insert("reasoning_effort".into(), json!(self.effort));
}
if !self.note.is_empty() {
m.insert("note".into(), json!(self.note));
}
m.insert("at".into(), json!(rfc3339(self.at)));
Value::Object(m)
}
/// Le sous-ensemble que lit la politique d'écho.
pub fn verdict(&self) -> super::wire::TplVerdict {
super::wire::TplVerdict {
model: self.model.clone(),
renders_reasoning: self.renders_reasoning,
preserves_history: self.preserves_history,
}
}
}
/// Instant au format RFC 3339 (UTC, nanosecondes), comme `time.Time`.
pub fn rfc3339(t: SystemTime) -> String {
let d = t.duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = d.as_secs() as i64;
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Jours depuis 1970 → date civile (algorithme de Howard Hinnant).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
let mut s = format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}",
rem / 3600,
rem % 3600 / 60,
rem % 60
);
let ns = d.subsec_nanos();
if ns > 0 {
let frac = format!("{ns:09}");
s.push('.');
s.push_str(frac.trim_end_matches('0'));
}
s.push('Z');
s
}
/// `tplShape` : la forme de la requête à reproduire.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Shape {
/// Outils figés en JSON ; `None` = aucun outil envoyé.
pub tools: Option<String>,
pub n_tools: usize,
pub first: String,
pub kwargs: Option<Map<String, Value>>,
pub effort: String,
}
impl Shape {
/// `newTplShape`.
pub fn new(tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) -> Self {
let mut s = Shape {
n_tools: tools.len(),
effort: effort.into(),
..Shape::default()
};
if !tools.is_empty() {
s.tools = Some(gojson::marshal(tools));
s.first = tools[0].function.name.clone();
}
s.kwargs = kwargs.cloned();
s
}
/// `tplShape.hash` : FNV-64a des outils, des kwargs et de l'effort.
pub fn hash(&self) -> String {
let mut h = Fnv::new();
if let Some(t) = &self.tools {
h.write(t.as_bytes());
}
h.write(&[0]);
let kw = match &self.kwargs {
Some(k) => gojson::marshal(&Value::Object(k.clone())),
None => "null".into(),
};
h.write(kw.as_bytes());
h.write(&[0]);
h.write(self.effort.as_bytes());
format!("{:016x}", h.0)
}
}
/// FNV-1a 64 bits (`hash/fnv.New64a`).
struct Fnv(u64);
impl Fnv {
fn new() -> Self {
Fnv(0xcbf2_9ce4_8422_2325)
}
fn write(&mut self, b: &[u8]) {
for &c in b {
self.0 ^= u64::from(c);
self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
}
}
}
fn fnv_hex(b: &[u8]) -> String {
let mut h = Fnv::new();
h.write(b);
format!("{:016x}", h.0)
}
/// `tplMsg`.
#[derive(Serialize, Clone)]
struct Msg {
role: &'static str,
content: &'static str,
#[serde(skip_serializing_if = "str::is_empty")]
reasoning_content: &'static str,
#[serde(skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<ToolCall>,
#[serde(skip_serializing_if = "str::is_empty")]
tool_call_id: &'static str,
}
fn msg(role: &'static str, content: &'static str) -> Msg {
Msg {
role,
content,
reasoning_content: "",
tool_calls: Vec::new(),
tool_call_id: "",
}
}
fn call(name: &str) -> ToolCall {
ToolCall {
index: None,
id: CALL_ID.into(),
kind: "function".into(),
function: ToolCallFunc {
name: name.into(),
arguments: "{}".into(),
},
}
}
/// Réponse du moteur : non-200 (`tplHTTPError`) ou autre échec.
#[derive(Debug, Clone, PartialEq)]
pub enum ProbeError {
Http { status: u16, body: String },
Other(String),
}
impl std::fmt::Display for ProbeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProbeError::Http { status, body } if !body.is_empty() => {
write!(f, "HTTP {status} : {body}")
}
ProbeError::Http { status, .. } => write!(f, "HTTP {status}"),
ProbeError::Other(e) => f.write_str(e),
}
}
}
impl ProbeError {
/// `tplDefinitive` : l'échec se reproduira à l'identique.
fn definitive(&self) -> bool {
match self {
ProbeError::Http { status, .. } => {
!matches!(status, 401 | 403 | 503 | 429) && (400..600).contains(status)
}
ProbeError::Other(_) => false,
}
}
}
/// `/props` : ce que la sonde en retient.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Props {
pub build_info: String,
pub model_path: String,
pub chat_template: String,
}
/// `tplProber` : le moteur local.
#[derive(Clone)]
pub struct Prober {
pub base: String,
/// Clé Bearer du moteur ("" = aucune).
pub key: String,
pub client: reqwest::Client,
/// Attente entre deux essais sur un 503 (`tplProbeBackoff`).
pub backoff: Duration,
}
impl Prober {
/// `tplLocalProber`.
pub fn local(port: i64, key: &str, backoff: Duration) -> Self {
crate::chat::engine::install_crypto();
Prober {
base: format!("http://localhost:{port}"),
key: key.into(),
client: reqwest::Client::builder()
.timeout(PROBE_TIMEOUT)
.build()
.unwrap_or_default(),
backoff,
}
}
async fn call(
&self,
method: reqwest::Method,
path: &str,
body: Option<Vec<u8>>,
) -> Result<Vec<u8>, ProbeError> {
let mut rq = self
.client
.request(method, format!("{}{path}", self.base))
.timeout(PROBE_TIMEOUT);
if let Some(b) = body {
rq = rq.header("Content-Type", "application/json").body(b);
}
if !self.key.is_empty() {
rq = rq.header("Authorization", format!("Bearer {}", self.key));
}
let mut resp = rq
.send()
.await
.map_err(|e| ProbeError::Other(e.to_string()))?;
let status = resp.status().as_u16();
let mut data = Vec::new();
while data.len() < BODY_MAX {
match resp.chunk().await {
Ok(Some(c)) => data.extend_from_slice(&c),
Ok(None) => break,
Err(e) => return Err(ProbeError::Other(e.to_string())),
}
}
data.truncate(BODY_MAX);
if status != 200 {
let msg = String::from_utf8_lossy(&data).trim().to_string();
let msg = if msg.chars().count() > 160 {
format!("{}…", msg.chars().take(160).collect::<String>())
} else {
msg
};
return Err(ProbeError::Http { status, body: msg });
}
Ok(data)
}
/// `/health` à 200 ?
pub async fn healthy(&self) -> bool {
self.call(reqwest::Method::GET, "/health", None)
.await
.is_ok()
}
/// `/props`.
pub async fn props(&self) -> Result<Props, ProbeError> {
let data = self.call(reqwest::Method::GET, "/props", None).await?;
let bad = || ProbeError::Other("réponse non JSON".into());
let v: Value = serde_json::from_slice(&data).map_err(|_| bad())?;
let mut p = Props::default();
match v {
Value::Null => return Ok(p),
Value::Object(m) => {
for (k, dst) in [
("build_info", &mut p.build_info),
("model_path", &mut p.model_path),
("chat_template", &mut p.chat_template),
] {
match m.get(k) {
None | Some(Value::Null) => {}
Some(Value::String(s)) => *dst = s.clone(),
Some(_) => return Err(bad()),
}
}
}
_ => return Err(bad()),
}
Ok(p)
}
/// `render` : POST /apply-template, relancé sur 503.
async fn render(
&self,
shape: &Shape,
msgs: &[Msg],
gen_prompt: bool,
) -> Result<String, ProbeError> {
#[derive(Serialize)]
struct Body<'a> {
add_generation_prompt: bool,
#[serde(skip_serializing_if = "Option::is_none")]
chat_template_kwargs: Option<&'a Map<String, Value>>,
messages: &'a [Msg],
#[serde(skip_serializing_if = "Option::is_none")]
parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<&'a RawValue>,
}
let raw_tools = shape
.tools
.as_deref()
.and_then(|t| serde_json::from_str::<&RawValue>(t).ok());
let raw = gojson::marshal(&Body {
add_generation_prompt: gen_prompt,
chat_template_kwargs: shape.kwargs.as_ref(),
messages: msgs,
parallel_tool_calls: raw_tools.map(|_| false),
reasoning_effort: (!shape.effort.is_empty()).then_some(shape.effort.as_str()),
tools: raw_tools,
});
let mut tries = 1;
loop {
match self
.call(
reqwest::Method::POST,
"/apply-template",
Some(raw.clone().into_bytes()),
)
.await
{
Err(ProbeError::Http { status: 503, .. }) if tries < TRIES_503 => {
tokio::time::sleep(self.backoff * tries).await;
tries += 1;
}
Err(e) => return Err(e),
Ok(data) => {
let prompt = serde_json::from_slice::<Value>(&data)
.ok()
.and_then(|v| v.get("prompt").cloned());
return match prompt {
Some(Value::String(p)) => Ok(p),
_ => Err(ProbeError::Http {
status: 200,
body: "réponse sans prompt".into(),
}),
};
}
}
}
}
/// `probe` : les rendus et leurs verdicts.
pub async fn probe(&self, shape: &Shape) -> ProbeResult {
let mut r = ProbeResult::unknown("");
let mut definitive = true;
let mut notes: Vec<String> = Vec::new();
let mut fail = |what: &str, e: &ProbeError, notes: &mut Vec<String>| {
if !e.definitive() {
definitive = false;
}
notes.push(format!("{what} : {e}"));
};
let sys = msg("system", MARK_SYS);
let user_a = msg("user", MARK_A);
let user_b = msg("user", MARK_B);
let mut past = msg("assistant", MARK_C1);
past.reasoning_content = MARK_R1;
// 1) Raisonnement d'un tour passé.
match self
.render(
shape,
&[sys.clone(), user_a.clone(), past, user_b.clone()],
false,
)
.await
{
Err(e) => fail("historique", &e, &mut notes),
Ok(h) if !h.contains(MARK_C1) || !h.contains(MARK_B) => {
notes.push("historique : rendu incohérent".into());
}
Ok(h) if h.contains(MARK_R1) => r.preserves_history = TplTri::Yes,
Ok(_) => r.preserves_history = TplTri::No,
}
// 2) Stabilité du préfixe à travers un tour d'outil.
let name = if shape.first.is_empty() {
"lookup"
} else {
&shape.first
};
let mut c = msg("assistant", MARK_C1);
c.reasoning_content = MARK_R1;
c.tool_calls = vec![call(name)];
let mut result = msg("tool", MARK_T1);
result.tool_call_id = CALL_ID;
let before = vec![sys, user_a, c, result];
let mut after = before.clone();
after.push(user_b);
let r1 = self.render(shape, &before, false).await;
let mut r1g: Result<String, ProbeError> = Err(ProbeError::Other(String::new()));
let mut r2: Result<String, ProbeError> = Err(ProbeError::Other(String::new()));
if r1.is_ok() {
r1g = self.render(shape, &before, true).await;
}
if r1.is_ok() && r1g.is_ok() {
r2 = self.render(shape, &after, false).await;
}
match (&r1, &r1g, &r2) {
(Err(e), _, _) | (Ok(_), Err(e), _) | (Ok(_), Ok(_), Err(e)) => {
fail("préfixe", e, &mut notes)
}
(Ok(a), Ok(g), _) if a == g => {
notes.push("add_generation_prompt ignoré par le moteur".into())
}
(Ok(a), Ok(_), Ok(b)) if !a.contains(MARK_T1) || !b.contains(MARK_B) => {
notes.push("préfixe : rendu incohérent".into())
}
(Ok(a), Ok(_), Ok(b)) if b.starts_with(a.as_str()) => r.prefix_stable = TplTri::Yes,
_ => r.prefix_stable = TplTri::No,
}
let r1_has = |m: &str| r1.as_ref().is_ok_and(|s| s.contains(m));
if r.preserves_history == TplTri::Yes || r1_has(MARK_R1) {
r.renders_reasoning = TplTri::Yes;
} else if r1_has(MARK_T1) && r.preserves_history == TplTri::No {
r.renders_reasoning = TplTri::No;
notes.push("reasoning_content jamais rendu".into());
}
r.note = notes.join(" ; ");
r.cacheable = definitive;
r
}
/// `tplRenderPrint` : les rendus concaténés de trois conversations
/// synthétiques sur la forme donnée.
pub async fn render_print(&self, shape: &Shape) -> Result<String, String> {
let sys = msg("system", MARK_SYS);
let user_a = msg("user", MARK_A);
let user_b = msg("user", MARK_B);
let name = if shape.first.is_empty() {
"lookup"
} else {
&shape.first
};
let mut with_r = msg("assistant", MARK_C1);
with_r.reasoning_content = MARK_R1;
let mut c = msg("assistant", MARK_C1);
c.tool_calls = vec![call(name)];
let mut result = msg("tool", MARK_T1);
result.tool_call_id = CALL_ID;
let convs: [(&str, Vec<Msg>); 3] = [
(
"historique sans raisonnement",
vec![
sys.clone(),
user_a.clone(),
msg("assistant", MARK_C1),
user_b.clone(),
],
),
(
"historique avec raisonnement",
vec![sys.clone(), user_a.clone(), with_r, user_b.clone()],
),
("tour d'outil", vec![sys, user_a, c, result, user_b]),
];
let mut b = String::new();
for (name, msgs) in convs {
let out = self
.render(shape, &msgs, true)
.await
.map_err(|e| format!("{name} : {e}"))?;
b.push_str(&format!("{RENDER_SEP}{name} ---\n"));
b.push_str(&out);
}
Ok(b)
}
}
/// `tplRenderDiff` : identiques → (true, "") ; sinon la conversation où les
/// relevés divergent et un extrait de part et d'autre du premier écart.
pub fn render_diff(before: &str, after: &str) -> (bool, String) {
if before == after {
return (true, String::new());
}
let (bb, ab) = (before.as_bytes(), after.as_bytes());
let mut i = 0;
while i < bb.len() && i < ab.len() && bb[i] == ab[i] {
i += 1;
}
let mut section = String::new();
if let Some(k) = find_last(&bb[..i], RENDER_SEP.as_bytes()) {
let rest = &bb[k + RENDER_SEP.len()..];
if let Some(e) = find(rest, b" ---") {
section = String::from_utf8_lossy(&rest[..e]).into_owned();
}
}
let excerpt = |s: &[u8]| -> String {
let mut from = i.saturating_sub(40);
let mut to = (i + 60).min(s.len());
while from > 0 && !rune_start(s[from]) {
from -= 1;
}
while to < s.len() && !rune_start(s[to]) {
to += 1;
}
let mut x = String::from_utf8_lossy(&s[from..to])
.replace('\n', "⏎")
.replace('\x00', "");
if from > 0 {
x = format!("…{x}");
}
if to < s.len() {
x.push('…');
}
x
};
let mut d = format!("avant « {} » / après « {} »", excerpt(bb), excerpt(ab));
if !section.is_empty() {
d = format!("{section} : {d}");
}
(false, d)
}
fn rune_start(b: u8) -> bool {
b & 0xC0 != 0x80
}
fn find(h: &[u8], n: &[u8]) -> Option<usize> {
h.windows(n.len()).position(|w| w == n)
}
fn find_last(h: &[u8], n: &[u8]) -> Option<usize> {
if h.len() < n.len() {
return None;
}
(0..=h.len() - n.len())
.rev()
.find(|&i| &h[i..i + n.len()] == n)
}
/// `tplProbeLine`.
pub fn probe_line(r: &ProbeResult) -> String {
let dash = |s: &str| {
if s.is_empty() {
"-".to_string()
} else {
s.to_string()
}
};
let mut s = format!(
"[tplprobe] build={} modèle={} gabarit={} outils={} historique_raisonnement={} préfixe_stable={} raisonnement_rendu={}",
dash(&r.build),
dash(&r.model),
dash(&r.template_hash),
r.tools,
r.preserves_history.as_str(),
r.prefix_stable.as_str(),
r.renders_reasoning.as_str()
);
if !r.note.is_empty() {
s.push_str(&format!(" ({})", r.note));
}
s
}
/// Où sonder : le moteur local, sa clé, et si un preset externe est actif.
#[derive(Debug, Clone, Default)]
pub struct Env {
pub external: bool,
pub port: i64,
pub key: String,
}
#[derive(Default)]
struct State {
cache: HashMap<String, ProbeResult>,
last: Option<ProbeResult>,
by_shape: HashMap<String, ProbeResult>,
running: bool,
tried: Option<Instant>,
logged: String,
shape_last: Option<Shape>,
}
/// La sonde : son cache et ses derniers verdicts (l'état global du Go).
pub struct TplProbe {
state: Mutex<State>,
/// Déclenchement automatique après les complétions (`tplProbeAuto`).
pub auto: bool,
/// Attente entre deux essais sur un 503.
pub backoff: Duration,
/// Intervalle minimal entre deux vérifications automatiques.
pub every: Duration,
}
impl Default for TplProbe {
fn default() -> Self {
TplProbe::new()
}
}
/// Le plus ancien verdict d'une table (pour lui faire de la place).
fn oldest(m: &HashMap<String, ProbeResult>) -> Option<String> {
m.iter().min_by_key(|(_, v)| v.at).map(|(k, _)| k.clone())
}
impl TplProbe {
pub fn new() -> Self {
TplProbe {
state: Mutex::new(State::default()),
auto: true,
backoff: Duration::from_secs(1),
every: PROBE_EVERY,
}
}
fn st(&self) -> std::sync::MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
/// `tplLastShape` : la forme de la dernière requête sondée, ou une
/// requête nue.
pub fn last_shape(&self) -> Shape {
self.st().shape_last.clone().unwrap_or_default()
}
/// `tplCapsCurrent` : le dernier verdict, rien pour un preset externe.
pub fn caps_current(&self, external: bool) -> Option<ProbeResult> {
if external {
return None;
}
self.st().last.clone()
}
/// `tplCapsFor` : le dernier verdict pour CETTE forme.
pub fn caps_for(&self, external: bool, shape: &Shape) -> Option<ProbeResult> {
if external {
return None;
}
self.st().by_shape.get(&shape.hash()).cloned()
}
/// Pose le verdict d'une forme comme si la sonde venait de conclure
/// (tests, et reprise d'un verdict connu).
pub fn remember(&self, shape: &Shape, r: ProbeResult) {
let mut st = self.st();
st.last = Some(r.clone());
Self::keep_shape(&mut st, &shape.hash(), &r);
}
/// `tplProbeKeepShape` (verrou tenu).
fn keep_shape(st: &mut State, shape: &str, r: &ProbeResult) {
if !st.by_shape.contains_key(shape)
&& st.by_shape.len() >= CACHE_MAX
&& let Some(k) = oldest(&st.by_shape)
{
st.by_shape.remove(&k);
}
st.by_shape.insert(shape.into(), r.clone());
}
/// `tplProbeStore`.
fn store(&self, key: &str, shape: &str, r: &ProbeResult) {
let mut st = self.st();
st.last = Some(r.clone());
Self::keep_shape(&mut st, shape, r);
if key.is_empty() || !r.cacheable {
return;
}
if st.cache.len() >= CACHE_MAX
&& let Some(k) = oldest(&st.cache)
{
st.cache.remove(&k);
}
st.cache.insert(key.into(), r.clone());
}
/// Le prober du moteur local.
pub fn prober(&self, env: &Env) -> Prober {
Prober::local(env.port, &env.key, self.backoff)
}
/// `tplProbeEnsure` : verdict pour la forme, depuis le cache ou une sonde.
pub async fn ensure(&self, env: &Env, shape: &Shape) -> ProbeResult {
if env.external {
return ProbeResult::unknown("preset externe : pas de sonde");
}
self.st().shape_last = Some(shape.clone());
let p = self.prober(env);
if !p.healthy().await {
return ProbeResult::unknown("moteur pas prêt (/health)");
}
let props = match p.props().await {
Ok(pr) => pr,
Err(e) => {
let r = ProbeResult::unknown(&format!("/props illisible : {e}"));
self.store("", &shape.hash(), &r);
return r;
}
};
let tmpl_hash = fnv_hex(props.chat_template.as_bytes());
let key = format!(
"{}\x00{}\x00{tmpl_hash}\x00{}",
props.build_info,
props.model_path,
shape.hash()
);
{
let mut st = self.st();
if let Some(r) = st.cache.get(&key).cloned() {
st.last = Some(r.clone());
Self::keep_shape(&mut st, &shape.hash(), &r);
return r;
}
}
let mut r = p.probe(shape).await;
r.build = props.build_info.clone();
r.template_hash = tmpl_hash;
r.tools = shape.n_tools;
r.kwargs = shape.kwargs.clone();
r.effort = shape.effort.clone();
if !props.model_path.is_empty() {
r.model = crate::paths::base_name(&props.model_path);
}
let line = probe_line(&r);
let fresh = {
let mut st = self.st();
let fresh = line != st.logged;
st.logged = line.clone();
fresh
};
if fresh {
eprintln!("{line}");
}
self.store(&key, &shape.hash(), &r);
r
}
/// `tplProbeKick` : après une complétion complète du fil principal, en
/// tâche de fond ; au plus une sonde en vol, au plus une par `every`.
pub fn kick(self: &std::sync::Arc<Self>, env: Env, shape: Shape) {
if !self.auto {
return;
}
{
let mut st = self.st();
if st.running || st.tried.is_some_and(|t| t.elapsed() < self.every) {
return;
}
st.running = true;
st.tried = Some(Instant::now());
}
let me = self.clone();
tokio::spawn(async move {
let _ = tokio::time::timeout(Duration::from_secs(30), me.ensure(&env, &shape)).await;
me.st().running = false;
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fnv64a() {
// Vecteurs de référence FNV-1a 64 bits.
assert_eq!(fnv_hex(b""), "cbf29ce484222325");
assert_eq!(fnv_hex(b"a"), "af63dc4c8601ec8c");
}
#[test]
fn instant_rfc3339() {
assert_eq!(rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00Z");
assert_eq!(
rfc3339(UNIX_EPOCH + Duration::new(1_709_251_200, 500_000_000)),
"2024-03-01T00:00:00.5Z"
);
}
#[test]
fn json_omitempty() {
let mut r = ProbeResult::unknown("");
r.kwargs = Some(Map::new());
let v = r.to_json();
assert!(v.get("chat_template_kwargs").is_none());
assert!(v.get("note").is_none());
assert_eq!(v["tools"], 0);
}
}
+35 -4
View File
@@ -24,9 +24,9 @@
//! l'historique ([`TurnResult::extra`]).
//!
//! Non portés (clés opt-in, ou machinerie du moteur local restée au Go) :
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT (slot et
//! rejeux « cache KV plein »), SLOT_PERSIST, préchauffage, sonde de gabarit,
//! télémétrie `perfRecord`, comptage des requêtes en vol du moteur. Avec l'une
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, SIDE_SLOT (slot et rejeux « cache KV
//! plein »), SLOT_PERSIST, préchauffage, télémétrie `perfRecord`, comptage
//! des requêtes en vol du moteur. Avec l'une
//! de ces clés, le Go garde la conversation (`facadeChatBlocker`, décidé au
//! démarrage).
@@ -152,6 +152,20 @@ pub trait TurnHost: Send + Sync {
}
/// Messages mis en file par l'utilisateur, à injecter maintenant.
fn drain_queued(&self) -> Vec<Message>;
/// Complétion lue en entier sur le moteur local, outils ni coupés ni
/// neutralisés : la sonde de gabarit peut rejouer sa forme
/// (`tplProbeKick`).
fn completed(&self, _tools: &[Tool], _kwargs: Option<&Map<String, Value>>, _effort: &str) {}
/// La sonde a-t-elle conclu « préfixe instable » pour CE modèle et pour
/// cette forme de requête (`nudgeInToolOn`, hors clé et preset externe) ?
fn prefix_unstable(
&self,
_tools: &[Tool],
_kwargs: Option<&Map<String, Value>>,
_effort: &str,
) -> bool {
false
}
/// Prépare ce que [`Self::image_data_url`] devra rendre pour ces messages
/// (lecture et déchiffrement des images rangées), avant chaque requête.
fn prepare_images(&self, _msgs: &[Message]) -> impl Future<Output = ()> + Send {
@@ -384,7 +398,19 @@ pub async fn run_turn<H: TurnHost>(
if !nudge.is_empty() {
s.budget_nudges += 1;
log_budget(s.tool_runs, budget, s.budget_nudges);
append_nudge(&mut messages, &mut extra, &nudge);
// NUDGE_IN_TOOL (opt-in, gabarits dont le rendu bouge quand un
// message user s'ajoute) : au bout du dernier résultat d'outil.
// La sonde ne connaît que la forme normale : une relance d'une
// autre forme se replie sur le message à part.
let placed = !s.disable_tools
&& !s.tool_choice_none
&& super::nudges::nudge_in_tool_key(&tc.cfg)
&& !ep.external
&& host.prefix_unstable(&tc.tools, kwargs.as_ref(), &effort)
&& super::nudges::nudge_into_tool(&mut messages, &mut extra, &nudge);
if !placed {
append_nudge(&mut messages, &mut extra, &nudge);
}
}
let calls_on = !tc.tools.is_empty() && !s.disable_tools && !s.tool_choice_none;
let pol = turn_echo_policy(ep.external || s.echo_off_turn, || tc.echo.clone());
@@ -585,6 +611,11 @@ pub async fn run_turn<H: TurnHost>(
if out.is_complete(read_err.is_some()) {
// Dernier événement de la complétion : copie complète des stats.
cb(StreamEvent::Stats(out.stats.clone()));
// Sonde de gabarit, en tâche de fond et après coup : rien n'en
// revient dans la requête suivante.
if !ep.external && !s.disable_tools && !s.tool_choice_none {
host.completed(&tc.tools, kwargs.as_ref(), &effort);
}
if !ep.external {
s.peak_gen = s.peak_gen.max(out.stats.gen_tokens);
if s.last_est > 0 && out.stats.prompt_tokens_total > 0 {
+125
View File
@@ -17,6 +17,9 @@ enum Script {
Cut(Vec<String>),
}
/// Une complétion signalée à la sonde : (outils, kwargs, effort).
type Completion = (usize, Option<Map<String, Value>>, String);
#[derive(Default)]
struct Fake {
script: Mutex<VecDeque<Script>>,
@@ -29,6 +32,10 @@ struct Fake {
efforts: Mutex<Vec<(String, String)>>,
/// L'arrêt à déclencher pendant l'exécution d'un outil.
stop_on_exec: Mutex<Option<watch::Sender<bool>>>,
/// Verdict « préfixe instable » de la sonde (NUDGE_IN_TOOL).
unstable: Mutex<bool>,
/// Complétions signalées à la sonde : (outils, kwargs, effort).
completions: Mutex<Vec<Completion>>,
}
impl Fake {
@@ -127,6 +134,15 @@ impl TurnHost for Fake {
self.efforts.lock().unwrap().push((want.into(), got.into()));
}
fn echo_refuse(&self, _model: &str) {}
fn completed(&self, tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) {
self.completions
.lock()
.unwrap()
.push((tools.len(), kwargs.cloned(), effort.into()));
}
fn prefix_unstable(&self, _t: &[Tool], _k: Option<&Map<String, Value>>, _e: &str) -> bool {
*self.unstable.lock().unwrap()
}
}
fn content(s: &str) -> String {
@@ -339,6 +355,17 @@ async fn erreur_500_tool_choice_none_puis_outils_retires() {
);
assert!(host.body(2).get("tools").is_none(), "puis retirés");
assert_eq!(host.body(2)["messages"][0]["role"], "system");
// Une relance outils coupés n'est pas la forme normale : pas de sonde.
assert!(host.completions.lock().unwrap().is_empty());
// Pas davantage outils neutralisés (tool_choice « none »).
let host = Fake::new(vec![
Script::Status(500, "parse error".into()),
answer("texte"),
]);
let (r, _) = run(&host, &config(&["glob"]), &Cancel::never()).await;
assert_eq!(r.err, None);
assert_eq!(host.body(1)["tool_choice"], "none");
assert!(host.completions.lock().unwrap().is_empty());
}
#[tokio::test]
@@ -471,6 +498,104 @@ async fn budget_d_outils_rappel() {
}));
}
/// NUDGE_IN_TOOL : avec la clé et un gabarit « préfixe instable », le rappel
/// part au bout du dernier résultat d'outil (envoyé et gardé à l'identique),
/// sans message user à part ; sans verdict, ou sans la clé, à part.
#[tokio::test]
async fn rappel_de_budget_dans_le_resultat() {
for (key, unstable, inside) in [
("on", true, true),
("on", false, false),
("", true, false),
("Oui", true, true),
("external", true, false),
] {
let mut script: Vec<Script> = (0..3)
.map(|i| {
tool_step(
&format!("c{i}"),
"bash",
&format!("{{\"command\":\"ls {i}\"}}"),
)
})
.collect();
script.push(answer("fin"));
let host = Fake::new(script);
*host.unstable.lock().unwrap() = unstable;
let mut tc = config(&["bash"]);
tc.cfg.insert("AGENT_BUDGET".into(), "2".into());
if key == "external" {
// Preset externe : le rappel reste à part, quoi que dise la sonde.
let ext = HashMap::from([
("EXTERNAL".to_string(), "1".to_string()),
(
"EXTERNAL_URL".to_string(),
"http://127.0.0.1:1/v1".to_string(),
),
]);
tc.ep = resolve_chat_endpoint(&ext, "");
tc.cfg.insert("NUDGE_IN_TOOL".into(), "on".into());
} else {
tc.cfg.insert("NUDGE_IN_TOOL".into(), key.into());
}
let (r, _) = run(&host, &tc, &Cancel::never()).await;
let msgs = host.body(2)["messages"].as_array().unwrap().clone();
let last = msgs.last().unwrap();
let ctx = format!("{key}/{unstable}");
if inside {
assert_eq!(last["role"], "tool", "{ctx}");
let c = last["content"].as_str().unwrap();
assert!(
c.starts_with("résultat de bash\n\n") && c.contains("2 tool calls"),
"{ctx} : {c}"
);
assert!(
!msgs.iter().any(|m| m["role"] == "user"
&& m["content"].as_str().unwrap_or("").contains("2 tool calls")),
"{ctx}"
);
let kept = r.extra.iter().filter(|m| m.role == "tool").nth(1).unwrap();
assert_eq!(
kept.content.as_ref().and_then(Value::as_str),
Some(c),
"{ctx} : historique"
);
} else {
assert_eq!(last["role"], "user", "{ctx}");
assert!(
last["content"].as_str().unwrap().contains("2 tool calls"),
"{ctx}"
);
}
}
}
/// Chaque complétion lue en entier est signalée à la sonde, avec la forme
/// de la requête ; pas sur un preset externe.
#[tokio::test]
async fn completions_signalees_a_la_sonde() {
let host = Fake::new(vec![tool_step("c1", "bash", "{}"), answer("fin")]);
let mut tc = config(&["bash"]);
tc.reasoning.effort = "low".into();
let (_, _) = run(&host, &tc, &Cancel::never()).await;
assert_eq!(
*host.completions.lock().unwrap(),
vec![(1, None, "low".to_string()), (1, None, "low".to_string())]
);
let host = Fake::new(vec![answer("fin")]);
let mut tc = config(&["bash"]);
let mut cfg = HashMap::new();
cfg.insert("EXTERNAL".to_string(), "1".to_string());
cfg.insert(
"EXTERNAL_URL".to_string(),
"http://127.0.0.1:1/v1".to_string(),
);
tc.ep = resolve_chat_endpoint(&cfg, "");
assert!(tc.ep.external);
let (_, _) = run(&host, &tc, &Cancel::never()).await;
assert!(host.completions.lock().unwrap().is_empty());
}
#[tokio::test]
async fn arret_pendant_un_outil() {
let host = Fake::new(vec![tool_step("c1", "bash", r#"{"command":"sleep 100"}"#)]);
+17
View File
@@ -65,6 +65,7 @@ pub fn is_internal(path: &str) -> bool {
| "/internal/facade/v1/reload"
| "/internal/facade/v1/project/switch"
| "/internal/facade/v1/project/drop"
| "/internal/facade/v1/chat/tpl"
)
}
@@ -90,6 +91,22 @@ pub async fn serve_internal(chat: &Arc<ChatService>, req: Request<Incoming>) ->
&json!({ "error": "événement illisible" }),
),
},
// Dernier verdict de la sonde de gabarit (`tplCapsCurrent`), pour le
// résumé des performances que sert encore le Go.
"/internal/facade/v1/chat/tpl" => {
let cfg = chat.shared.store.config().await;
match chat
.shared
.tpl
.caps_current(crate::engstatus::is_external(&cfg))
{
Some(r) => send_json(
StatusCode::OK,
&json!({ "ok": true, "result": r.to_json() }),
),
None => send_json(StatusCode::OK, &json!({ "ok": false })),
}
}
"/internal/facade/v1/project/switch" | "/internal/facade/v1/project/drop" => {
let v = read_json(req.into_body()).await.unwrap_or_default();
let slug = str_of(&v, "slug");
+195 -1
View File
@@ -497,9 +497,10 @@ use loki_rs::chat::compact::{CompactCaps, CompactConfig, CompactMemory};
use loki_rs::chat::engine::EngineHttp;
use loki_rs::chat::host::{ChatShared, EngineSummarizer, GoTurnHost, ImageCache, TurnCompact};
use loki_rs::chat::stream::RetryPatterns;
use loki_rs::chat::tplprobe::{ProbeResult, Shape};
use loki_rs::chat::turn::{Cancel, TurnConfig, run_turn};
use loki_rs::chat::types::{Message, StreamEvent, Tool, ToolFunction};
use loki_rs::chat::wire::{ChatEndpoint, EchoRefusals, EffortFallbacks, TurnReasoning};
use loki_rs::chat::wire::{ChatEndpoint, EchoRefusals, EffortFallbacks, TplTri, TurnReasoning};
/// Faux moteur : rend les réponses SSE dans l'ordre, garde les requêtes.
async fn fake_engine(
@@ -541,6 +542,22 @@ async fn fake_engine_gated(
if req.uri().path() == "/health" {
return ok("ok".into());
}
// Sonde de gabarit (lancée après chaque complétion
// du fil principal) : un gabarit qui rend toujours
// la même chose. Ses rendus ne comptent pas parmi
// les requêtes vues.
if req.uri().path() == "/props" {
return ok(r#"{"build_info":"b1-x","model_path":"/m/m1.gguf","chat_template":"t"}"#.into());
}
if req.uri().path() == "/apply-template" {
let body = req.into_body().collect().await.unwrap().to_bytes();
let b: serde_json::Value = serde_json::from_slice(&body).unwrap();
PROBE_RENDERS.lock().unwrap().push(b.clone());
return ok(
serde_json::json!({ "prompt": render_without_reasoning(&b) })
.to_string(),
);
}
let auth = req
.headers()
.get("authorization")
@@ -578,6 +595,34 @@ async fn fake_engine_gated(
(format!("http://{addr}/v1/chat/completions"), seen)
}
/// Un gabarit qui ne rend jamais `reasoning_content` : rôle, texte, appels
/// d'outils, et l'amorce de réponse si elle est demandée.
fn render_without_reasoning(b: &serde_json::Value) -> String {
let mut out: Vec<String> = Vec::new();
for m in b["messages"].as_array().into_iter().flatten() {
let calls: Vec<&str> = m["tool_calls"]
.as_array()
.into_iter()
.flatten()
.filter_map(|c| c["function"]["name"].as_str())
.collect();
out.push(format!(
"{}:{}{}",
m["role"].as_str().unwrap_or(""),
m["content"].as_str().unwrap_or(""),
calls.join(",")
));
}
let mut p = out.join("\n");
if b["add_generation_prompt"] == true {
p.push_str("\nassistant:");
}
p
}
/// Rendus demandés par la sonde de gabarit, tous moteurs confondus.
static PROBE_RENDERS: StdMutex<Vec<serde_json::Value>> = StdMutex::new(Vec::new());
fn sse(chunks: &[serde_json::Value]) -> String {
chunks
.iter()
@@ -620,6 +665,7 @@ async fn tour_complet_par_le_go() {
compact_memory: CompactMemory::new(),
images: ImageCache::new(imgdir),
patterns: RetryPatterns::builtin(),
tpl: Arc::new(loki_rs::chat::tplprobe::TplProbe::new()),
});
let cfg = std::collections::HashMap::new();
let host = GoTurnHost {
@@ -872,6 +918,154 @@ async fn tour_de_bout_en_bout() {
assert!(again.dup);
}
/// Lot 23 : après un tour, la façade sonde le gabarit du moteur avec la
/// forme de la requête (mêmes outils), et range le verdict que le Go relit
/// pour son résumé des performances.
#[tokio::test]
async fn sonde_de_gabarit_apres_un_tour() {
let (url, _seen) = fake_engine(tool_then_answer("fini")).await;
let (svc, _go, _stop, _t) = service(Some(&url)).await;
svc.send(serde_json::json!({"message": "liste", "cid": "s1"}))
.await
.unwrap();
until_done(&svc, 1).await;
until(|| svc.shared.tpl.caps_current(false).is_some()).await;
let r = svc.shared.tpl.caps_current(false).unwrap();
assert_eq!(r.tools, 1);
assert_eq!(r.build, "b1-x");
assert_eq!(r.model, "m1.gguf");
assert_eq!(r.note, "reasoning_content jamais rendu");
assert_eq!(
(r.preserves_history, r.prefix_stable, r.renders_reasoning),
(TplTri::No, TplTri::Yes, TplTri::No)
);
let renders = PROBE_RENDERS.lock().unwrap().clone();
assert!(
renders
.iter()
.any(|b| b["tools"][0]["function"]["name"] == "bash"
&& b["parallel_tool_calls"] == false),
"{renders:?}"
);
// Un preset externe : rien d'affiché.
assert!(svc.shared.tpl.caps_current(true).is_none());
}
/// Le verdict de la sonde décide pour l'hôte : rappel dans le résultat
/// d'outil seulement si le préfixe est instable pour CE modèle ; sonde
/// lancée pour le fil principal, jamais pour une tâche.
#[tokio::test]
async fn hote_et_sonde_de_gabarit() {
let (svc, _go, _stop, _t) = service(None).await;
let shared = svc.shared.clone();
let host = |main: &str, model: &str| {
let cfg = std::collections::HashMap::from([("MODEL".to_string(), model.to_string())]);
let ep = ChatEndpoint {
url: "http://127.0.0.1:1/v1/chat/completions".into(),
model: "loki".into(),
key: String::new(),
external: false,
};
GoTurnHost {
shared: shared.clone(),
conv_id: "c1".into(),
caps: serde_json::json!({}),
cfg: cfg.clone(),
port: 1,
summarizer: EngineSummarizer {
http: shared.http.clone(),
ep,
cfg,
patterns: shared.patterns.clone(),
port: 1,
},
compact: TurnCompact {
cfg: CompactConfig::default(),
caps: CompactCaps::default(),
criteria: String::new(),
main_conv: main.into(),
tools: vec![],
effort: String::new(),
kwargs: Default::default(),
},
queued: Box::new(Vec::new),
}
};
let bash = Tool {
kind: "function".into(),
function: ToolFunction {
name: "bash".into(),
description: String::new(),
parameters: serde_json::json!({"type":"object"}),
},
};
let tools = vec![bash];
let shape = Shape::new(&tools, None, "");
let mut r = ProbeResult::unknown("");
r.model = "m1.gguf".into();
r.prefix_stable = TplTri::No;
shared.tpl.remember(&shape, r.clone());
use loki_rs::chat::turn::TurnHost;
assert!(host("c1", "/m/m1.gguf").prefix_unstable(&tools, None, ""));
assert!(!host("c1", "/m/autre.gguf").prefix_unstable(&tools, None, ""));
assert!(!host("c1", "/m/m1.gguf").prefix_unstable(&[], None, ""));
r.prefix_stable = TplTri::Yes;
shared.tpl.remember(&shape, r.clone());
assert!(!host("c1", "/m/m1.gguf").prefix_unstable(&tools, None, ""));
r.prefix_stable = TplTri::No;
r.model = String::new();
shared.tpl.remember(&shape, r);
assert!(!host("c1", "").prefix_unstable(&tools, None, ""));
// Une tâche (pas de discussion principale) ne lance pas la sonde.
host("", "").completed(&tools, None, "");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(shared.tpl.last_shape(), Shape::default());
host("c1", "").completed(&tools, None, "");
until(|| shared.tpl.last_shape() == shape).await;
}
/// REASONING_ECHO : un gabarit qui ne rend jamais le raisonnement (verdict de
/// la sonde, après le premier tour) coupe l'écho au tour suivant.
#[tokio::test]
async fn echo_coupe_par_le_verdict_de_la_sonde() {
let reasoned = sse(&[
serde_json::json!({"choices":[{"delta":{"reasoning_content": "je réfléchis"}}]}),
serde_json::json!({"choices":[{"delta":{"content": "un"}}]}),
serde_json::json!({"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":40,"completion_tokens":2}}),
]);
let (url, seen) = fake_engine(vec![reasoned.clone(), reasoned]).await;
let (svc, go, _stop, _t) = service(Some(&url)).await;
go.set("config", "REASONING_ECHO", "on");
go.set("config", "MODEL", "/m/m1.gguf");
svc.send(serde_json::json!({"message": "q1", "cid": "e1"}))
.await
.unwrap();
until_done(&svc, 1).await;
until(|| svc.shared.tpl.caps_current(false).is_some()).await;
assert_eq!(
svc.shared
.tpl
.caps_current(false)
.unwrap()
.renders_reasoning,
TplTri::No
);
svc.send(serde_json::json!({"message": "q2", "cid": "e2"}))
.await
.unwrap();
until_done(&svc, 2).await;
let seen = seen.lock().unwrap().clone();
let second = &seen.last().unwrap().1;
let past = second["messages"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "assistant")
.unwrap();
assert!(past.get("reasoning_content").is_none(), "{past}");
}
/// Contenu seul, fin normale, avec l'usage (une mesure par complétion).
fn answer(text: &str) -> String {
sse(&[
File diff suppressed because it is too large. Load diff
+264
View File
@@ -0,0 +1,264 @@
//! Lot 23 : sonde de gabarit portée en Rust (`chat/tplprobe.rs`).
//!
//! Le journal tiré du Go (`internal/loki/parity_tplprobe_test.go` →
//! `tests/parity_tplprobe/tplprobe.json`) est rejoué échange par échange : un
//! faux moteur rend les réponses enregistrées dans l'ordre et vérifie que le
//! port envoie, octet pour octet, les mêmes requêtes (méthode, chemin, clé,
//! corps). Puis verdicts, lignes de journal, derniers verdicts (courant et par
//! forme), cache, relevés du rendu, comparaisons et empreintes des formes.
use std::collections::VecDeque;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::chat::tplprobe::{self as tp, Env, Prober, Shape, TplProbe};
use loki_rs::chat::types::Tool;
use serde_json::{Map, Value};
use tokio::net::TcpListener;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_tplprobe/tplprobe.json")).expect("journal")
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or("")
}
/// Le faux moteur : les échanges attendus, et les écarts constatés.
#[derive(Default)]
struct Script {
expected: VecDeque<Value>,
errors: Vec<String>,
}
async fn fake_engine(script: Arc<Mutex<Script>>) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = l.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let script = script.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let script = script.clone();
async move {
let method = req.method().to_string();
let path = req.uri().path().to_string();
let auth = req
.headers()
.get("authorization")
.map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned())
.unwrap_or_default();
let body = req.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&body).into_owned();
let mut sc = script.lock().unwrap();
let (code, reply) = match sc.expected.pop_front() {
None => {
sc.errors
.push(format!("requête en trop : {method} {path} {body}"));
(599, String::new())
}
Some(want) => {
let got =
(method.as_str(), path.as_str(), auth.as_str(), body.as_str());
let exp = (
s(&want["method"]),
s(&want["path"]),
s(&want["auth"]),
s(&want["body"]),
);
if got != exp {
sc.errors.push(format!("attendu {exp:?}\n reçu {got:?}"));
}
(
want["status"].as_u64().unwrap() as u16,
s(&want["reply"]).to_string(),
)
}
};
let mut r = Response::new(Full::new(Bytes::from(reply)));
*r.status_mut() = hyper::StatusCode::from_u16(code).unwrap();
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(st), svc)
.await;
});
}
});
port
}
fn shape(fx: &Value, name: &str) -> Shape {
let sh = &fx["shapes"][name];
let tools: Vec<Tool> = if s(&sh["tools"]).is_empty() {
Vec::new()
} else {
serde_json::from_str(s(&sh["tools"])).unwrap()
};
let kwargs: Option<Map<String, Value>> = sh["kwargs"].as_object().cloned();
Shape::new(&tools, kwargs.as_ref(), s(&sh["effort"]))
}
/// Le verdict comme le journal le range : sans l'instant, avec `cacheable`.
fn result_json(r: &tp::ProbeResult) -> Value {
let mut v = r.to_json();
let m = v.as_object_mut().unwrap();
m.remove("at");
m.insert("cacheable".into(), Value::Bool(r.cacheable));
v
}
fn opt_json(r: Option<tp::ProbeResult>) -> Value {
r.map(|r| result_json(&r)).unwrap_or(Value::Null)
}
fn take(script: &Arc<Mutex<Script>>, ctx: &str) {
let mut sc = script.lock().unwrap();
assert!(sc.errors.is_empty(), "{ctx} :\n{}", sc.errors.join("\n"));
assert!(
sc.expected.is_empty(),
"{ctx} : échanges non faits {:?}",
sc.expected
);
sc.errors.clear();
}
#[tokio::test]
async fn parite_des_verdicts() {
let fx = fixture();
for sc in fx["scenarios"].as_array().unwrap() {
let name = s(&sc["name"]);
let script = Arc::new(Mutex::new(Script::default()));
let port = fake_engine(script.clone()).await;
let mut probe = TplProbe::new();
probe.auto = false;
probe.backoff = Duration::from_millis(1);
// Preset externe : décidé sur la configuration, comme le Go.
let cfg: std::collections::HashMap<String, String> = sc["config"]
.as_object()
.into_iter()
.flatten()
.map(|(k, v)| (k.clone(), s(v).to_string()))
.collect();
let env = Env {
external: loki_rs::engstatus::is_external(&cfg),
port: port.into(),
key: s(&sc["api_key"]).to_string(),
};
assert_eq!(
env.external,
sc["external"].as_bool().unwrap_or(false),
"{name}"
);
for (i, st) in sc["steps"].as_array().unwrap().iter().enumerate() {
let ctx = format!("{name} #{i}");
script.lock().unwrap().expected = st["exchanges"]
.as_array()
.into_iter()
.flatten()
.cloned()
.collect();
let sh = shape(&fx, s(&st["shape"]));
let r = probe.ensure(&env, &sh).await;
take(&script, &ctx);
assert_eq!(result_json(&r), st["result"], "{ctx} : verdict");
assert_eq!(tp::probe_line(&r), s(&st["line"]), "{ctx} : journal");
assert_eq!(
opt_json(probe.caps_current(env.external)),
st["current"],
"{ctx} : courant"
);
assert_eq!(
opt_json(probe.caps_for(env.external, &sh)),
st["for"],
"{ctx} : forme"
);
// Un preset externe n'affiche jamais le verdict du moteur local.
assert!(probe.caps_current(true).is_none(), "{ctx}");
assert!(probe.caps_for(true, &sh).is_none(), "{ctx}");
}
}
}
#[tokio::test]
async fn parite_des_releves() {
let fx = fixture();
let script = Arc::new(Mutex::new(Script::default()));
let port = fake_engine(script.clone()).await;
let p = Prober::local(port.into(), "", Duration::from_millis(1));
for (i, pc) in fx["prints"].as_array().unwrap().iter().enumerate() {
script.lock().unwrap().expected = pc["exchanges"]
.as_array()
.into_iter()
.flatten()
.cloned()
.collect();
let got = p.render_print(&shape(&fx, s(&pc["shape"]))).await;
take(&script, &format!("relevé #{i}"));
match got {
Ok(out) => {
assert_eq!(out, s(&pc["out"]), "relevé #{i}");
assert_eq!(s(&pc["err"]), "", "relevé #{i}");
}
Err(e) => {
assert_eq!(e, s(&pc["err"]), "relevé #{i}");
assert_eq!(s(&pc["out"]), "", "relevé #{i}");
}
}
}
for d in fx["diffs"].as_array().unwrap() {
let (same, detail) = tp::render_diff(s(&d["before"]), s(&d["after"]));
assert_eq!(same, d["same"].as_bool().unwrap(), "{d}");
assert_eq!(detail, s(&d["detail"]), "{d}");
}
for (name, h) in fx["hashes"].as_object().unwrap() {
assert_eq!(shape(&fx, name).hash(), s(h), "empreinte {name}");
}
}
/// La sonde automatique : une à la fois, au plus une par intervalle, et
/// jamais quand elle est coupée.
#[tokio::test]
async fn declenchement_borne() {
let script = Arc::new(Mutex::new(Script::default()));
let port = fake_engine(script.clone()).await;
let env = Env {
external: false,
port: port.into(),
key: String::new(),
};
let health =
|| serde_json::json!({"method": "GET", "path": "/health", "status": 503, "reply": ""});
let mut off = TplProbe::new();
off.auto = false;
let off = Arc::new(off);
off.kick(env.clone(), Shape::default());
tokio::time::sleep(Duration::from_millis(100)).await;
take(&script, "coupée");
let mut p = TplProbe::new();
p.every = Duration::from_millis(300);
let p = Arc::new(p);
script.lock().unwrap().expected = [health()].into();
p.kick(env.clone(), Shape::default());
p.kick(env.clone(), Shape::default()); // encore en vol : rien
tokio::time::sleep(Duration::from_millis(100)).await;
take(&script, "première");
p.kick(env.clone(), Shape::default()); // finie, mais trop tôt : rien
tokio::time::sleep(Duration::from_millis(50)).await;
take(&script, "trop tôt");
tokio::time::sleep(Duration::from_millis(300)).await;
script.lock().unwrap().expected = [health()].into();
p.kick(env.clone(), Shape::default());
tokio::time::sleep(Duration::from_millis(100)).await;
take(&script, "après l'intervalle");
// La forme sondée en dernier est retenue pour le relevé du rendu.
assert_eq!(p.last_shape(), Shape::default());
}