diff --git a/README.md b/README.md index 29fd6fb..326aaeb 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot23-sonde-gabarit-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot23-sonde-gabarit-design.md new file mode 100644 index 0000000..81c5144 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot23-sonde-gabarit-design.md @@ -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. diff --git a/internal/loki/chat_tplprobe.go b/internal/loki/chat_tplprobe.go index 3a89001..108dad3 100644 --- a/internal/loki/chat_tplprobe.go +++ b/internal/loki/chat_tplprobe.go @@ -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 { diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 96b665b..e4996b8 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -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 diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 1c1fdf2..cd0c1d6 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -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") + } +} diff --git a/internal/loki/parity_tplprobe_test.go b/internal/loki/parity_tplprobe_test.go new file mode 100644 index 0000000..a06735d --- /dev/null +++ b/internal/loki/parity_tplprobe_test.go @@ -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 & é","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: ""}}}}, + {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) + } +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index a81882f..5a48246 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -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()), } } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 0a78b28..337ed94 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -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, + /// Sonde de gabarit (`chat_tplprobe.go`) : derniers verdicts et cache. + pub tpl: Arc, } /// 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>, + 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>, 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; } diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index 3a6725c..e2edcdc 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -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; diff --git a/rust/loki-rs/src/chat/nudges.rs b/rust/loki-rs/src/chat/nudges.rs index 21248d7..9f59135 100644 --- a/rust/loki-rs/src/chat/nudges.rs +++ b/rust/loki-rs/src/chat/nudges.rs @@ -75,6 +75,45 @@ pub fn append_nudge(messages: &mut Vec, extra: &mut Vec, text: } } +/// La clé `NUDGE_IN_TOOL` est-elle posée (`nudgeInToolOn`, sans la sonde) ? +pub fn nudge_in_tool_key(cfg: &std::collections::HashMap) -> 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())); + } } diff --git a/rust/loki-rs/src/chat/tplprobe.rs b/rust/loki-rs/src/chat/tplprobe.rs new file mode 100644 index 0000000..86f5cd5 --- /dev/null +++ b/rust/loki-rs/src/chat/tplprobe.rs @@ -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>, + 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, + pub n_tools: usize, + pub first: String, + pub kwargs: Option>, + pub effort: String, +} + +impl Shape { + /// `newTplShape`. + pub fn new(tools: &[Tool], kwargs: Option<&Map>, 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, + #[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>, + ) -> Result, 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::()) + } 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 { + 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 { + #[derive(Serialize)] + struct Body<'a> { + add_generation_prompt: bool, + #[serde(skip_serializing_if = "Option::is_none")] + chat_template_kwargs: Option<&'a Map>, + messages: &'a [Msg], + #[serde(skip_serializing_if = "Option::is_none")] + parallel_tool_calls: Option, + #[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::(&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 = Vec::new(); + let mut fail = |what: &str, e: &ProbeError, notes: &mut Vec| { + 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 = Err(ProbeError::Other(String::new())); + let mut r2: Result = 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 { + 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); 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 { + h.windows(n.len()).position(|w| w == n) +} + +fn find_last(h: &[u8], n: &[u8]) -> Option { + 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, + last: Option, + by_shape: HashMap, + running: bool, + tried: Option, + logged: String, + shape_last: Option, +} + +/// La sonde : son cache et ses derniers verdicts (l'état global du Go). +pub struct TplProbe { + state: Mutex, + /// 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) -> Option { + 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 { + 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 { + 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, 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); + } +} diff --git a/rust/loki-rs/src/chat/turn.rs b/rust/loki-rs/src/chat/turn.rs index 7eb782f..b7638d7 100644 --- a/rust/loki-rs/src/chat/turn.rs +++ b/rust/loki-rs/src/chat/turn.rs @@ -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; + /// 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>, _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>, + _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 + Send { @@ -384,7 +398,19 @@ pub async fn run_turn( 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( 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 { diff --git a/rust/loki-rs/src/chat/turn/tests.rs b/rust/loki-rs/src/chat/turn/tests.rs index 50d6505..e9040c5 100644 --- a/rust/loki-rs/src/chat/turn/tests.rs +++ b/rust/loki-rs/src/chat/turn/tests.rs @@ -17,6 +17,9 @@ enum Script { Cut(Vec), } +/// Une complétion signalée à la sonde : (outils, kwargs, effort). +type Completion = (usize, Option>, String); + #[derive(Default)] struct Fake { script: Mutex>, @@ -29,6 +32,10 @@ struct Fake { efforts: Mutex>, /// L'arrêt à déclencher pendant l'exécution d'un outil. stop_on_exec: Mutex>>, + /// Verdict « préfixe instable » de la sonde (NUDGE_IN_TOOL). + unstable: Mutex, + /// Complétions signalées à la sonde : (outils, kwargs, effort). + completions: Mutex>, } 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>, effort: &str) { + self.completions + .lock() + .unwrap() + .push((tools.len(), kwargs.cloned(), effort.into())); + } + fn prefix_unstable(&self, _t: &[Tool], _k: Option<&Map>, _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