diff --git a/README.md b/README.md index 6beea7f..9b4fec9 100644 --- a/README.md +++ b/README.md @@ -227,6 +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. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. 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 | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md new file mode 100644 index 0000000..e54f210 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md @@ -0,0 +1,93 @@ +# Lot 7 — outils de l'agent exécutés par la façade + +## Contexte + +Lot 6 : quand la façade tient la conversation (`LOKI_CHAT=rust`), la boucle du +tour est en Rust mais chaque appel d'outil repart au Go +(`/internal/v1/tool/exec`). Lot 7 : porter les outils eux-mêmes, en retirant +les appels au Go un par un. Rien ne change sans `LOKI_CHAT=rust` : la boucle +Go exécute ses outils comme avant. + +## Étape 1 — fichiers et shell (fait) + +Exécutés par la façade (`rust/loki-rs/src/tools/`) : `read`, `grep`, `glob`, +`write`, `edit`, `bash`, `ask`, avec les mêmes garde-fous dans le même ordre +(commande catastrophique, `&` final en mode Code, bac à sable du mode Code, +dossier mémoire interdit, « lu avant d'écrire »), le même diff pour l'UI et le +même nettoyage de la sortie du shell (`| tail -N`, ANSI, fin bornée). + +L'état partagé reste au Go, en un seul exemplaire, derrière trois routes +internes (`web_internal_toolstate.go`) : + +| Route | Rôle | +|---|---| +| `POST /internal/v1/tool/context` | dossier de travail (`agentCwd`), dossier mémoire, cible distante | +| `POST /internal/v1/tool/tracker` | `read` : lecture notée ; `check` : refus éventuel avant écriture | +| `POST /internal/v1/tool/wrote` | après une écriture : note du suivi, bloc des diagnostics LSP | + +Le suivi reste au Go parce que les sous-agents et les tâches planifiées, qui y +tournent, le lisent et l'écrivent aussi ; les serveurs LSP y vivent. + +Repli sur le Go : outil non porté ; `write`/`edit`/`bash` quand l'agent vise +un poste distant (une commande catastrophique est refusée avant, ici) ; +contexte indisponible. + +Le jeton de l'API interne est retiré de l'environnement du shell de l'agent. + +### Parité + +`internal/loki/parity_tools_test.go` passe 58 appels à `execTool` sur une +arborescence de test et range le journal (libellés, résultats, diffs, +questions, arborescence finale) dans `rust/loki-rs/tests/parity_tools/` ; +`rust/loki-rs/tests/tools.rs` rejoue les mêmes appels dans `exec_local` et +retrouve le même journal. Le test Go échoue si le comportement Go change +(`LOKI_PARITY_UPDATE=1` pour régénérer, puis le test Rust dit si le port suit). + +### Écarts assumés + +- Motif `grep` invalide : le message d'erreur est celui du moteur d'expressions + de Rust (crate `regex`), pas celui de Go. +- Classes `\w`, `\b`, `\s`, `\d` d'un motif `grep` : Unicode en Rust, ASCII en + Go (un mot accentué y est un mot entier). +- Octets hors UTF-8 dans un fichier lu ou cherché : remplacés par U+FFFD par + séquence invalide (Go : par octet, à l'encodage JSON). `edit` travaille sur + les octets et laisse intact ce qu'il ne remplace pas. +- `mkdir` en échec : le chemin cité est le dossier parent visé, pas le + composant qui a échoué. + +## Étape 2 — boucle du contrat du mode Code (fait) + +`codeVerifyLoop` porté (`rust/loki-rs/src/chat/verify.rs`) : à la fin d'un +tour de build en mode Code, relances du builder sur les critères encore +ouverts, passe de vérification sur un contexte isolé (`POST +/internal/v1/turn/verifier` : prompt du rôle et faits de la machine, outils +fermés, rôle `verifier` qui seul peut marquer `passed`), corrections, abandon +dit dans le fil après deux corrections. Mêmes deltas que le Go (`role`, +`verify_done`, mesures `ctx_isolated`). Les critères sont lus en base +(`chat/crit:`) ; l'outil `criteria` reste au Go. Toute compaction en mode +Code rend leur état au résumé, comme `compactMessages`. + +Écart assumé : l'isolation du slot du moteur après la vérification +(`engineSideJob`) reste au Go, qui ne voit pas passer les requêtes de la +façade. + +## Étape 3 — git (fait) + +`git_status`, `git_diff`, `git_clone` (`rust/loki-rs/src/tools/git.rs`) : +dépôt deviné comme le Go (sous-dossier demandé, racine, unique sous-dossier +dépôt), sorties mêlées et bornées, jamais d'invite, clone limité à https:// et +git@hôte: et rangé dans le dossier de la discussion. Toujours sur cette +machine, cible distante ou non (comme le Go). Journal de référence +`tests/parity_tools/git.json` (`TestPariteOutilsGit`, deux dépôts, aucun appel +réseau), rejoué par `tests/tools_git.rs`. + +## Étapes suivantes + +1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : le + cache de pages et la conversion HTML → Markdown (bibliothèques Go) ne se + reproduisent pas à l'octet ; à porter avec un journal de référence + structurel plutôt qu'exact. +2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) : ses travaux sont lus par + l'optimiseur et la suppression d'une discussion, au Go. +3. Mémoire (`mem_*`, `recall*`, `tracker`) : avec le portage du chiffrement. +4. Tâches, navigateur (CDP), MCP, sous-agents, critères. diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 4929e55..70faff8 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -88,6 +88,8 @@ type Conversation struct { // errBenchBusy pour un bench, errTuneBusy pour l'optimiseur, qui prend le // même verrou (backend_tune_job.go). leaseWhy error + // extLease : le verrou est tenu par la façade Rust (web_internal_lease.go). + extLease *extLeaseState // File d'attente des messages envoyés PENDANT une génération (AJEAN // 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière @@ -200,6 +202,7 @@ func LoadConversation() { // Une génération n'a pas pu survivre à l'arrêt du process : on repart propre. conv.Generating = false conv.cancel = nil + conv.extLeaseClearLocked() } // loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil @@ -235,6 +238,7 @@ func (c *Conversation) loadFrom(id string, b []byte) { if !c.benching { c.Generating = false c.cancel = nil + c.extLeaseClearLocked() // le tour de la façade est rompu, comme un tour Go } c.epoch++ c.cond.Broadcast() @@ -1199,7 +1203,8 @@ func (c *Conversation) Reset() { c.benching = false // Stop a annulé le bench : le verrou est rendu ici c.leaseWhy = nil c.cancel = nil - c.queued = nil // les messages en attente visaient le fil qu'on vient de vider + c.extLeaseClearLocked() // Reset débloque aussi un tour de la façade + c.queued = nil // les messages en attente visaient le fil qu'on vient de vider c.cond.Broadcast() c.mu.Unlock() c.persist() diff --git a/internal/loki/chat_export.go b/internal/loki/chat_export.go index 306fbf7..36456af 100644 --- a/internal/loki/chat_export.go +++ b/internal/loki/chat_export.go @@ -378,11 +378,12 @@ func exportFilename(ext string) string { // exportBody rend la conversation selon les options. Renvoie le corps, son type // MIME et l'extension de fichier. func exportBody(o exportOpts) ([]byte, string, string, error) { + src := exportSource() // la façade peut tenir la conversation (facade_chat.go) if o.Format == "json" { - b, err := conv.ExportJSON(o) + b, err := src.ExportJSON(o) return b, "application/json; charset=utf-8", "json", err } - return []byte(conv.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil + return []byte(src.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil } // cmdExport écrit la conversation dans un fichier (ou sur la sortie standard). diff --git a/internal/loki/code_criteria.go b/internal/loki/code_criteria.go index ae19082..eb7830c 100644 --- a/internal/loki/code_criteria.go +++ b/internal/loki/code_criteria.go @@ -214,14 +214,15 @@ func toolCriteria(args map[string]any, allowPass bool) string { // conversation. Best-effort : hors génération il n'y a pas d'epoch en cours, // on publie sur l'epoch courant. func critNotify(list []Criterion) { - if conv == nil { + if list == nil { + list = []Criterion{} + } + delta := map[string]any{"criteria": list, "ts": time.Now().UnixMilli()} + if facadeDelta(delta) || conv == nil { return } conv.mu.Lock() epoch := conv.epoch conv.mu.Unlock() - if list == nil { - list = []Criterion{} - } - conv.appendDelta(epoch, map[string]any{"criteria": list, "ts": time.Now().UnixMilli()}) + conv.appendDelta(epoch, delta) } diff --git a/internal/loki/code_mode.go b/internal/loki/code_mode.go index 8b4d285..4a51181 100644 --- a/internal/loki/code_mode.go +++ b/internal/loki/code_mode.go @@ -86,18 +86,26 @@ func convHasRepo() bool { // maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y // prête. Une seule fois par discussion : une suggestion répétée est du bruit. func maybeCodeHint(c *Conversation, epoch int, text string) { + if codeHintDue(text) { + c.appendDelta(epoch, map[string]any{"code_hint": true}) + } +} + +// codeHintDue dit si la puce doit apparaître pour ce message, et la marque +// alors comme montrée pour la discussion active (une fois par discussion). +func codeHintDue(text string) bool { if convCodeMode() || !agentEnabled() { - return + return false } id := convEnsureActive() if getBool(bkChat, hintKey(id)) { - return + return false } if !looksLikeCode(text) && !convHasRepo() { - return + return false } _ = putBool(bkChat, hintKey(id), true) - c.appendDelta(epoch, map[string]any{"code_hint": true}) + return true } // --- API --------------------------------------------------------------------- @@ -124,10 +132,12 @@ func handleChatMode(w http.ResponseWriter, r *http.Request) { } // Pousse la bascule à tous les appareils abonnés (le sélecteur doit // suivre, comme le fil lui-même). - conv.mu.Lock() - epoch := conv.epoch - conv.mu.Unlock() - conv.appendDelta(epoch, map[string]any{"mode": body.Mode}) + if !facadeDelta(map[string]any{"mode": body.Mode}) { + conv.mu.Lock() + epoch := conv.epoch + conv.mu.Unlock() + conv.appendDelta(epoch, map[string]any{"mode": body.Mode}) + } sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode}) return } diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go new file mode 100644 index 0000000..1bd95de --- /dev/null +++ b/internal/loki/facade_chat.go @@ -0,0 +1,335 @@ +package loki + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// facade_chat.go — conversation tenue par la façade Rust (LOKI_CHAT=rust, lot 6). +// +// La façade sert elle-même les routes du chat sur le port public. Deux chemins +// arrivent pourtant ICI sans passer par elle : le tunnel du relais, qui sert ce +// mux nu (/api/e2e/*), et sa redistribution /api/e2e/req dans le mux local. Ils +// liraient la conversation du Go — périmée — et pourraient même l'écrire en +// base par-dessus celle de la façade. Quand la façade tient la conversation : +// +// - les routes du chat lui sont remises, authentifiées par le jeton interne +// (la requête l'a déjà été ici) ; +// - /api/e2e/chat lit son flux et le rechiffre événement par événement ; +// - les événements d'affichage nés ici (bascule du mode Code, critères) vont +// dans SON journal ; +// - l'export lit la discussion enregistrée ; +// - le déverrouillage de la mémoire lui fait recharger la discussion ; +// - changer de projet, ou en supprimer un, passe par elle : la discussion +// de ce process est périmée, l'enregistrer écraserait la sienne. + +const ( + facadeChatEnv = "LOKI_CHAT" + facadeAddrEnv = "LOKI_FACADE_ADDR" +) + +// facadeChat : la façade tient-elle la conversation ? Il faut aussi son +// adresse et le jeton interne, sans lesquels rien ne peut lui être remis — et +// aucun réglage qu'elle ne sait pas honorer (facadeChatBlocker). +func facadeChat() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" && facadeChatDecision() == "" +} + +// 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). +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): + return "SLOT_PERSIST" + case prewarmMode(cfg) != "": + return "PREWARM" + case strings.TrimSpace(getStr(bkState, "retry_patterns")) != "": + return "retry_patterns" + } + 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 +// (/internal/v1/chat/holder) et s'y range. +var ( + facadeDecisionMu sync.Mutex + facadeDecisionDone bool + facadeDecisionWhy string +) + +func facadeChatDecision() string { + facadeDecisionMu.Lock() + defer facadeDecisionMu.Unlock() + if !facadeDecisionDone { + facadeDecisionWhy = facadeChatBlocker(ReadConfig()) + facadeDecisionDone = true + if facadeDecisionWhy != "" { + fmt.Fprintf(os.Stderr, "[chat] %s posé : la conversation reste tenue par le Go (LOKI_CHAT=rust ignoré)\n", facadeDecisionWhy) + } + } + return facadeDecisionWhy +} + +// handleInternalChatHolder : qui tient la conversation ({"holder":"rust"} ou +// {"holder":"go","reason":…}). La façade s'y range au démarrage. +func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"}) + return + } + if facadeChat() { + sendJSON(w, http.StatusOK, map[string]any{"holder": "rust"}) + return + } + why := facadeChatDecision() + if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") { + why = facadeChatEnv + " absent" + } + sendJSON(w, http.StatusOK, map[string]any{"holder": "go", "reason": why}) +} + +func facadeAddr() string { return strings.TrimSpace(os.Getenv(facadeAddrEnv)) } + +// facadeChatRoutes : les routes que la façade sert quand elle tient la +// conversation (chatapi.rs). Le reste (mode Code, critères, pièces jointes, +// fichiers, export) reste ici. +var facadeChatRoutes = map[string]bool{ + "/api/chat": true, + "/api/chat/send": true, + "/api/chat/stop": true, + "/api/chat/reset": true, + "/api/chat/state": true, + "/api/chat/compact": true, + "/api/chat/tool-result": true, + "/api/conversations": true, + "/api/conversations/new": true, + "/api/conversations/switch": true, + "/api/conversations/rename": true, + "/api/conversations/delete": true, +} + +// withFacadeChat remet la requête à la façade quand elle tient la +// conversation. Posé APRÈS requireWebAuth : seule une requête authentifiée +// ici part, avec le jeton interne qui en tient lieu là-bas. +func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { + if !facadeChatRoutes[path] { + return h + } + return func(w http.ResponseWriter, r *http.Request) { + if !facadeChat() { + h(w, r) + return + } + facadeRelay(w, r) + } +} + +// facadeRelay : relais transparent (flux compris) vers la façade. +func facadeRelay(w http.ResponseWriter, r *http.Request) { + rp := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(&url.URL{Scheme: "http", Host: facadeAddr()}) + pr.Out.Header.Set(internalTokenHeader, internalToken()) + }, + FlushInterval: -1, // SSE : chaque trame part aussitôt + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { + sendJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": "conversation indisponible : " + err.Error()}) + }, + } + rp.ServeHTTP(w, r) +} + +// facadeClient : appels courts vers l'API interne de la façade. +var facadeClient = &http.Client{Timeout: 5 * time.Second} + +// facadePost appelle l'API interne de la façade. +func facadePost(path string, body any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+path, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, internalToken()) + resp, err := facadeClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("façade : %s", resp.Status) + } + return nil +} + +// facadeError : refus de la façade, avec son statut (409 : un tour tourne). +type facadeError struct { + status int + msg string +} + +func (e *facadeError) Error() string { return e.msg } + +// facadeCall appelle l'API interne de la façade et rend son refus éventuel +// tel quel (statut et message), pour les actions dont l'utilisateur attend +// la réponse. +func facadeCall(path string, body any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+path, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, internalToken()) + // Délai large : enregistrer une discussion et en ouvrir une autre passe + // par le disque (et par le chiffrement). + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return fmt.Errorf("conversation indisponible : %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 300 { + return nil + } + var out struct { + Error string `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + if out.Error == "" { + out.Error = "façade : " + resp.Status + } + return &facadeError{status: resp.StatusCode, msg: out.Error} +} + +// facadeProjectSwitch : la façade enregistre la discussion du projet quitté +// (avec son projet), fait changer le projet actif ici +// (/internal/v1/project/activate), puis ouvre une discussion du nouveau. +func facadeProjectSwitch(slug string) error { + return facadeCall("/internal/facade/v1/project/switch", map[string]any{"slug": slug}) +} + +// facadeDropProject : la façade supprime les discussions d'un projet (index, +// contenu, et ce qui les entoure ici par /internal/v1/conv/dropped). +func facadeDropProject(slug string) error { + return facadeCall("/internal/facade/v1/project/drop", map[string]any{"slug": slug}) +} + +// facadeDelta : un événement d'affichage né ici, journalisé par la façade +// (tous les appareils le reçoivent). Rend false si la façade ne tient pas la +// conversation : l'appelant journalise alors comme avant. +func facadeDelta(delta map[string]any) bool { + if !facadeChat() { + return false + } + if err := facadePost("/internal/facade/v1/delta", delta); err != nil { + fmt.Fprintf(os.Stderr, "[chat] événement non transmis à la façade : %v\n", err) + } + return true +} + +// facadeReload : la mémoire vient d'être déverrouillée ; la façade recharge la +// discussion si elle l'avait chargée vide. +func facadeReload() { + if !facadeChat() { + return + } + go func() { + if err := facadePost("/internal/facade/v1/reload", map[string]any{}); err != nil { + fmt.Fprintf(os.Stderr, "[chat] rechargement de la discussion par la façade : %v\n", err) + } + }() +} + +// facadeChatStream : /api/e2e/chat quand la façade tient la conversation. Son +// flux est lu ligne par ligne et chaque événement repart par out (scellé, un +// sceau par événement). Les battements de la façade sont ignorés : ce flux a +// les siens (sseHeartbeat). +func facadeChatStream(ctx context.Context, body []byte, out *sseStream) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+facadeAddr()+"/api/chat", bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, internalToken()) + resp, err := http.DefaultClient.Do(req) // sans délai : un flux dure ce qu'il dure + if err != nil { + if ctx.Err() == nil { + out.emit(map[string]any{"error": "conversation indisponible : " + err.Error()}) + } + return + } + defer resp.Body.Close() + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 0, 64<<10), 64<<20) + for sc.Scan() { + data, ok := strings.CutPrefix(sc.Text(), "data: ") + if !ok { + continue + } + var ev struct { + Choices []struct { + Delta map[string]any `json:"delta"` + } `json:"choices"` + } + dec := json.NewDecoder(strings.NewReader(data)) + dec.UseNumber() // les nombres repartent tels quels + if dec.Decode(&ev) != nil || len(ev.Choices) == 0 { + continue + } + if !out.emit(ev.Choices[0].Delta) { + return + } + } +} + +// exportSource : la conversation à exporter. Tenue par la façade, celle du Go +// est périmée : on lit la discussion enregistrée (à jour à chaque fin de tour). +func exportSource() *Conversation { + if !facadeChat() { + return conv + } + c := &Conversation{} + c.cond = sync.NewCond(&c.mu) + if b := storeBytes(bkChat, convKey(convEnsureActive())); len(b) > 0 { + _ = json.Unmarshal(b, c) + } + return c +} diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go new file mode 100644 index 0000000..70282f1 --- /dev/null +++ b/internal/loki/facade_chat_test.go @@ -0,0 +1,305 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// fausseFacade : enregistre ce qui lui arrive ; /api/chat rend un flux. +type fausseFacade struct { + mu sync.Mutex + vu []string // "METHODE chemin jeton-ok corps" + srv *httptest.Server + t *testing.T + sse string + reply string + // Réponse de l'API interne (0 : 204 sans corps). + internalStatus int + internalBody string +} + +// oublierDecisionFacade : la décision « qui tient la conversation » est prise +// une fois par process ; chaque test repart de zéro. +func oublierDecisionFacade(t *testing.T) { + t.Helper() + reset := func() { + facadeDecisionMu.Lock() + facadeDecisionDone, facadeDecisionWhy = false, "" + facadeDecisionMu.Unlock() + } + reset() + t.Cleanup(reset) +} + +func demarrerFacade(t *testing.T) *fausseFacade { + t.Helper() + oublierDecisionFacade(t) + f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + tok := r.Header.Get(internalTokenHeader) == jetonTest + f.mu.Lock() + f.vu = append(f.vu, r.Method+" "+r.URL.Path+" "+map[bool]string{true: "jeton", false: "sans-jeton"}[tok]+" "+string(b)) + f.mu.Unlock() + if r.URL.Path == "/api/chat" { + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, f.sse) + return + } + if strings.HasPrefix(r.URL.Path, "/internal/facade/") { + f.mu.Lock() + st, body := f.internalStatus, f.internalBody + f.mu.Unlock() + if st == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + w.WriteHeader(st) + io.WriteString(w, body) + return + } + io.WriteString(w, f.reply) + })) + t.Cleanup(f.srv.Close) + t.Setenv(internalTokenEnv, jetonTest) + t.Setenv(facadeChatEnv, "rust") + t.Setenv(facadeAddrEnv, strings.TrimPrefix(f.srv.URL, "http://")) + return f +} + +func (f *fausseFacade) recu() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.vu...) +} + +// Les routes du chat arrivées ici (tunnel, redistribution E2E) partent à la +// façade avec le jeton interne ; les autres restent ici ; sans la variable, +// rien ne change. +func TestFacadeRelaieLesRoutesDuChat(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + + w := httptest.NewRecorder() + withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{"message":"salut"}`))) + if w.Body.String() != f.reply { + t.Fatalf("réponse : %q", w.Body.String()) + } + if got := f.recu(); len(got) != 1 || got[0] != `POST /api/chat/send jeton {"message":"salut"}` { + t.Fatalf("reçu par la façade : %v", got) + } + // Mode Code : reste au Go. + w = httptest.NewRecorder() + withFacadeChat("/api/chat/mode", local)(w, httptest.NewRequest("POST", "/api/chat/mode", nil)) + if w.Body.String() != "go" { + t.Fatalf("mode relayé : %q", w.Body.String()) + } + // Sans LOKI_CHAT=rust : tout reste ici. + t.Setenv(facadeChatEnv, "") + w = httptest.NewRecorder() + withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", nil)) + if w.Body.String() != "go" || len(f.recu()) != 1 { + t.Fatalf("relayé sans la variable : %q", w.Body.String()) + } +} + +// /api/e2e/chat suit le journal de la façade : un événement par trame, les +// battements de la façade ignorés, les nombres repartent tels quels. +func TestFacadeFluxE2E(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + f.sse = "data: {\"choices\":[{\"delta\":{\"pad\":\"··\"}}]}\n\n" + + ": ping\n\n" + + "data: {\"choices\":[{\"delta\":{\"seq\":12345678901,\"user\":\"salut\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"caught_up\":true,\"id\":\"c1\"}}]}\n\n" + w := httptest.NewRecorder() + var trames []string + out := &sseStream{w: w, flusher: w, mu: &sync.Mutex{}, frame: func(obj map[string]any) ([]byte, bool) { + b, _ := json.Marshal(obj) + trames = append(trames, string(b)) + return []byte("data: " + string(b) + "\n\n"), true + }} + facadeChatStream(t.Context(), []byte(`{"from":0,"tail":20}`), out) + want := []string{`{"pad":"··"}`, `{"seq":12345678901,"user":"salut"}`, `{"caught_up":true,"id":"c1"}`} + if strings.Join(trames, "|") != strings.Join(want, "|") { + t.Fatalf("trames : %v", trames) + } + if got := f.recu(); len(got) != 1 || got[0] != `POST /api/chat jeton {"from":0,"tail":20}` { + t.Fatalf("abonnement : %v", got) + } +} + +// Les événements d'affichage nés ici vont au journal de la façade, pas à +// celui du Go. +func TestFacadeEvenementsEtExport(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + resetConvForTest() + critNotify([]Criterion{{ID: 1, Text: "les tests passent", Status: "pending"}}) + got := f.recu() + if len(got) != 1 || !strings.HasPrefix(got[0], "POST /internal/facade/v1/delta jeton ") || !strings.Contains(got[0], "les tests passent") { + t.Fatalf("critères : %v", got) + } + conv.mu.Lock() + n := len(conv.Log) + conv.mu.Unlock() + if n != 0 { + t.Fatal("événement journalisé aussi dans la conversation du Go") + } + + // Export : la discussion enregistrée, pas la conversation du Go. + id := convEnsureActive() + b, _ := json.Marshal(map[string]any{"messages": []Message{{Role: "user", Content: "fil de la façade"}}, "log": []any{}, "seq": 0, "ctx_used": 0}) + if err := putStoreBytes(bkChat, convKey(id), b); err != nil { + t.Fatal(err) + } + body, _, _, err := exportBody(exportOpts{Format: "json"}) + if err != nil || !strings.Contains(string(body), "fil de la façade") { + t.Fatalf("export : %v %s", err, body) + } +} + +// Changer de projet quand la façade tient la conversation : c'est elle qui +// enregistre sa discussion et en ouvre une autre (la copie d'ici est périmée, +// l'enregistrer écraserait la sienne). Son refus (tour en cours) remonte tel +// quel ; un projet inconnu est refusé ici sans la déranger. +func TestFacadeChangeDeProjet(t *testing.T) { + testHome(t) + ensureDefaultProject() + p, err := createProject("Deux") + if err != nil { + t.Fatal(err) + } + f := demarrerFacade(t) + post := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(body))) + return w + } + before := activeProjectSlug() + if w := post(`{"action":"switch","slug":"` + p.Slug + `"}`); w.Code != 200 { + t.Fatalf("bascule : %d %s", w.Code, w.Body.String()) + } + if got := f.recu(); len(got) != 1 || got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+p.Slug+`"}` { + t.Fatalf("reçu par la façade : %v", got) + } + // Le projet actif ne change que par la façade (/internal/v1/project/activate). + if activeProjectSlug() != before { + t.Fatalf("projet actif changé sans la façade : %s", activeProjectSlug()) + } + if w := post(`{"action":"switch","slug":"inconnu"}`); w.Code != 400 || len(f.recu()) != 1 { + t.Fatalf("projet inconnu : %d, façade %v", w.Code, f.recu()) + } + f.mu.Lock() + f.internalStatus, f.internalBody = 409, `{"ok":false,"error":"une génération est en cours"}` + f.mu.Unlock() + w := post(`{"action":"switch","slug":"` + p.Slug + `"}`) + if w.Code != 409 || !strings.Contains(w.Body.String(), "une génération est en cours") { + t.Fatalf("refus de la façade : %d %s", w.Code, w.Body.String()) + } +} + +// Supprimer le projet actif : la façade le quitte d'abord (discussion +// enregistrée, une du projet restant ouverte), puis supprime ses discussions. +func TestFacadeSuppressionDeProjet(t *testing.T) { + testHome(t) + ensureDefaultProject() + p, err := createProject("Deux") + if err != nil { + t.Fatal(err) + } + if err := setActiveProject(p.Slug); err != nil { + t.Fatal(err) + } + f := demarrerFacade(t) + w := httptest.NewRecorder() + handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(`{"action":"delete","slug":"`+p.Slug+`"}`))) + if w.Code != 200 { + t.Fatalf("suppression : %d %s", w.Code, w.Body.String()) + } + got := f.recu() + if len(got) != 2 || + got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+defaultProjectSlug+`"}` || + got[1] != `POST /internal/facade/v1/project/drop jeton {"slug":"`+p.Slug+`"}` { + t.Fatalf("reçu par la façade : %v", got) + } + if projectExists(p.Slug) { + t.Fatal("projet toujours là") + } +} + +// /internal/v1/project/activate : le projet actif change, s'il existe. +func TestFacadeActivationDeProjet(t *testing.T) { + testHome(t) + ensureDefaultProject() + p, err := createProject("Trois") + if err != nil { + t.Fatal(err) + } + t.Setenv(internalTokenEnv, jetonTest) + if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"`+p.Slug+`"}`); w.Code != 204 { + t.Fatalf("activation : %d %s", w.Code, w.Body.String()) + } + if activeProjectSlug() != p.Slug { + t.Fatalf("actif : %s", activeProjectSlug()) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"nulle-part"}`); w.Code != 400 { + t.Fatalf("inconnu : %d", w.Code) + } +} + +// Un réglage que la façade ne sait pas honorer garde la conversation ici : +// rien ne lui est remis, et elle l'apprend au démarrage. La décision tient +// jusqu'au redémarrage. +func TestFacadeReglageQuiGardeLaConversation(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(internalTokenEnv, jetonTest) + if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"holder":"rust"`) { + t.Fatalf("détenteur : %d %s", w.Code, w.Body.String()) + } + // Posé après la décision : sans effet avant le redémarrage. + if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil { + t.Fatal(err) + } + if !facadeChat() { + t.Fatal("décision changée en cours de route") + } + oublierDecisionFacade(t) + if facadeChat() { + t.Fatal("KEEP_TURN_IMAGES posé : la conversation doit rester au Go") + } + w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "") + if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "KEEP_TURN_IMAGES") { + t.Fatalf("détenteur : %s", w.Body.String()) + } + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + rec := httptest.NewRecorder() + withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`))) + if rec.Body.String() != "go" || len(f.recu()) != 0 { + 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", + "SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM", + } { + if got := facadeChatBlocker(map[string]string{k: "on"}); got != want { + t.Errorf("%s : %q", k, got) + } + } + if got := facadeChatBlocker(map[string]string{}); got != "" { + t.Errorf("sans réglage : %q", got) + } + // Sans LOKI_CHAT=rust : le Go, et pourquoi. + oublierDecisionFacade(t) + t.Setenv(facadeChatEnv, "") + if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "LOKI_CHAT absent") { + t.Fatalf("sans la variable : %s", w.Body.String()) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index c7d63ab..4ee38b6 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -263,6 +263,24 @@ func globalCaps() Caps { // skills directory (when skills are enabled). Merges with an existing system // message if present. tools = the tools sent on this same turn (see prepareTurn). func InjectSkills(msgs []Message, caps Caps, tools []Tool) []Message { + prefix := injectPrefix(caps, tools) + if prefix == "" { + return msgs + } + // Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le + // préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait + // plus le sortir du bloc système commun (voir isProjectSystem). + if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) { + existing, _ := msgs[0].Content.(string) + merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...) + return merged + } + return append([]Message{{Role: "system", Content: prefix}}, msgs...) +} + +// injectPrefix : le texte qu'InjectSkills pose en tête — préambule d'agent +// décidé, puis briefing machine ; "" s'il n'y a rien à dire. +func injectPrefix(caps Caps, tools []Tool) string { var parts []string // Decisive-agent preamble (anti-loop) — only when the model actually has // tools, otherwise it nudges a plain chat model to "call tools" it doesn't @@ -273,19 +291,7 @@ func InjectSkills(msgs []Message, caps Caps, tools []Tool) []Message { if mp := machineSystemPrompt(caps); mp != "" { parts = append(parts, mp) } - if len(parts) == 0 { - return msgs - } - prefix := strings.Join(parts, "\n\n") - // Un message PROJET en tête (pas de prompt de preset) ne doit pas absorber le - // préambule : il perdrait son préfixe, et normalizeSystemMessages ne saurait - // plus le sortir du bloc système commun (voir isProjectSystem). - if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) { - existing, _ := msgs[0].Content.(string) - merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...) - return merged - } - return append([]Message{{Role: "system", Content: prefix}}, msgs...) + return strings.Join(parts, "\n\n") } // prepareTurn calcule UNE fois les outils du tour, puis le préambule à partir @@ -2360,7 +2366,12 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools extra = append(extra, toolMsg) continue } - ex := execTool(ctx, tc.Function.Name, args, label, caps) + var ex toolExecResult + if execToolHook != nil { + ex = execToolHook(ctx, tc.Function.Name, args, label, caps) + } else { + ex = execTool(ctx, tc.Function.Name, args, label, caps) + } result, diff, diffAdd, diffDel, visionImg = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg if ex.Ask != nil { cb(StreamEvent{Ask: ex.Ask}) @@ -2603,6 +2614,10 @@ type toolExecResult struct { Ask *AskEvent `json:"ask,omitempty"` } +// execToolHook : nil en service. Les tests de parité Go/Rust +// (parity_test.go) y fixent les résultats des outils. +var execToolHook func(ctx context.Context, name string, args map[string]any, label string, caps Caps) toolExecResult + // execTool exécute UN appel d'outil. Extrait tel quel de runChatLoop (lot 6 // de la migration Rust) : c'est la frontière par laquelle une boucle de chat // écrite en Rust fera exécuter ses outils par ce process, tant qu'ils n'auront diff --git a/internal/loki/parity_test.go b/internal/loki/parity_test.go new file mode 100644 index 0000000..62cd9d7 --- /dev/null +++ b/internal/loki/parity_test.go @@ -0,0 +1,425 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// parity_test.go — journaux de référence de la boucle d'un tour (lot 6 de la +// migration Rust). +// +// Chaque scénario rejoue runChatTools contre un faux moteur aux réponses +// scriptées, outils aux résultats fixés (execToolHook). Ce qui en sort — corps +// des requêtes envoyées au moteur, événements d'affichage, messages ajoutés à +// l'historique, erreur — est rangé dans rust/loki-rs/tests/parity/.json. +// Le test Rust (tests/parity.rs) rejoue les mêmes scénarios dans chat::turn et +// doit retrouver EXACTEMENT ces journaux : l'octet envoyé au moteur compte (le +// cache de préfixe en dépend), l'affichage aussi. +// +// Ici, le journal produit doit être celui qui est rangé : un changement de +// comportement de la boucle Go se voit, et se régénère sciemment — +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteJournaux +// +// — après quoi le test Rust dit si le port suit. + +const parityDir = "../../rust/loki-rs/tests/parity" + +// parityReply : une réponse scriptée du moteur. Lignes du corps, chacune +// suivie d'une ligne vide (trames SSE). +type parityReply struct { + Status int `json:"status"` + Lines []string `json:"lines"` +} + +type parityScenario struct { + Name string `json:"name"` + Cfg map[string]string `json:"cfg"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools"` + Temperature float64 `json:"temperature"` + Replies []parityReply `json:"replies"` + ToolResults map[string]string `json:"tool_results"` +} + +type parityExpect struct { + Requests []any `json:"requests"` + Events []map[string]any `json:"events"` + Extra []Message `json:"extra"` + Err string `json:"err"` +} + +type parityFixture struct { + parityScenario + Expect parityExpect `json:"expect"` +} + +// --- Trames du faux moteur -------------------------------------------------- + +func pDelta(delta map[string]any) string { + b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": delta}}}) + return "data: " + string(b) +} + +func pContent(s string) string { return pDelta(map[string]any{"content": s}) } +func pReasoning(s string) string { return pDelta(map[string]any{"reasoning_content": s}) } + +func pToolCall(i int, id, name, args string) string { + return pDelta(map[string]any{"tool_calls": []any{map[string]any{ + "index": i, "id": id, "type": "function", + "function": map[string]any{"name": name, "arguments": args}, + }}}) +} + +func pFinish(reason string) string { + b, _ := json.Marshal(map[string]any{ + "choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": reason}}, + "usage": map[string]any{"prompt_tokens": 100, "completion_tokens": 5}, + }) + return "data: " + string(b) +} + +func pOK(lines ...string) parityReply { + return parityReply{Status: 200, Lines: append(lines, "data: [DONE]")} +} + +// --- Scénarios --------------------------------------------------------------- + +func parityScenarios() []parityScenario { + sys := Message{Role: "system", Content: "Tu es Loki."} + user := func(s string) Message { return Message{Role: "user", Content: s} } + tools := []Tool{readTool(), bashTool()} + base := func(name string, msgs []Message, replies ...parityReply) parityScenario { + return parityScenario{ + Name: name, Cfg: map[string]string{}, Messages: msgs, Tools: tools, + Temperature: 0.7, Replies: replies, + ToolResults: map[string]string{"read": "ligne 1\nligne 2", "bash": "a.txt\nb.txt"}, + } + } + return []parityScenario{ + base("reponse_simple", []Message{sys, user("Bonjour")}, + pOK(pContent("Bon"), pContent("jour !"), pFinish("stop"))), + + base("raisonnement_puis_reponse", []Message{sys, user("Combien font 2+2 ?")}, + pOK(pReasoning("Je calcule."), pReasoning(" 4."), pContent("4"), pFinish("stop"))), + + base("outil_puis_reponse", []Message{sys, user("Lis a.txt")}, + pOK(pToolCall(0, "c1", "read", `{"file":"a.txt"}`), pFinish("tool_calls")), + pOK(pContent("Le fichier a deux lignes."), pFinish("stop"))), + + base("deux_outils_en_parallele", []Message{sys, user("Liste puis lis")}, + pOK(pToolCall(0, "c1", "bash", `{"command":"ls"}`), + pToolCall(1, "c2", "read", `{"file":"a.txt"}`), pFinish("tool_calls")), + pOK(pContent("Fait."), pFinish("stop"))), + + base("appel_repete", []Message{sys, user("Lis a.txt")}, + pOK(pToolCall(0, "c1", "read", `{"file":"a.txt"}`), pFinish("tool_calls")), + pOK(pToolCall(0, "c2", "read", `{"file":"a.txt"}`), pFinish("tool_calls")), + pOK(pContent("Toujours deux lignes."), pFinish("stop"))), + + base("arguments_illisibles", []Message{sys, user("Lis a.txt")}, + pOK(pToolCall(0, "c1", "read", `{"file":`), pFinish("tool_calls")), + pOK(pContent("Pardon."), pFinish("stop"))), + + base("pense_sans_agir", []Message{sys, user("Cherche")}, + pOK(pReasoning("Il faudrait lister les fichiers."), pFinish("stop")), + pOK(pContent("Voilà."), pFinish("stop"))), + + base("erreur_500_avec_outils", []Message{sys, user("Bonjour")}, + parityReply{Status: 500, Lines: []string{`{"error":{"code":500,"message":"Failed to parse tool call","type":"server_error"}}`}}, + pOK(pContent("Salut."), pFinish("stop"))), + + base("erreur_moteur", []Message{sys, user("Bonjour")}, + parityReply{Status: 400, Lines: []string{`{"error":{"code":400,"message":"requête invalide","type":"invalid_request_error"}}`}}), + + base("appel_textuel", []Message{sys, user("Lis a.txt")}, + pOK(pContent(`{"name":"read","arguments":{"file":"a.txt"}}`), pFinish("stop")), + pOK(pToolCall(0, "c1", "read", `{"file":"a.txt"}`), pFinish("tool_calls")), + pOK(pContent("Deux lignes."), pFinish("stop"))), + + base("outil_inconnu", []Message{sys, user("Fais-le")}, + pOK(pToolCall(0, "c1", "voler", `{}`), pFinish("tool_calls")), + pOK(pContent("Impossible."), pFinish("stop"))), + + with(base("budget_depasse", []Message{sys, user("Explore")}, + pOK(pToolCall(0, "c1", "bash", `{"command":"ls 1"}`), pFinish("tool_calls")), + pOK(pToolCall(0, "c2", "bash", `{"command":"ls 2"}`), pFinish("tool_calls")), + pOK(pToolCall(0, "c3", "bash", `{"command":"ls 3"}`), pFinish("tool_calls")), + pOK(pContent("J'arrête là."), pFinish("stop"))), + "AGENT_BUDGET", "2"), + + base("pense_sans_agir_deux_fois", []Message{sys, user("Cherche")}, + pOK(pReasoning("Lister les fichiers."), pFinish("stop")), + pOK(pReasoning("Toujours lister."), pFinish("stop")), + pOK(pContent("Voilà."), pFinish("stop"))), + + with(base("pensee_en_ligne", []Message{sys, user("Salut")}, + pOK(pContent("Il dit bonjour."), pContent("Bonjour !"), pFinish("stop"))), + "REASONING", "on"), + + with(with(with(base("echantillonnage_et_effort", []Message{sys, user("Salut")}, + pOK(pContent("Bonjour."), pFinish("stop"))), + "TOP_P", "0.9"), "TOP_K", "40"), "REASONING_EFFORT", "low"), + + with(base("raisonnement_coupe", []Message{sys, user("Salut")}, + pOK(pContent("Bonjour."), pFinish("stop"))), + "REASONING", "off"), + + base("erreur_500_deux_fois", []Message{sys, user("Bonjour")}, + parityReply{Status: 500, Lines: []string{`{"error":{"code":500,"message":"Failed to parse tool call","type":"server_error"}}`}}, + parityReply{Status: 500, Lines: []string{`{"error":{"code":500,"message":"Failed to parse tool call","type":"server_error"}}`}}, + pOK(pContent("Salut."), pFinish("stop"))), + + with(with(with(base("api_externe", []Message{sys, user("Lis a.txt")}, + pOK(pReasoning("Je lis."), pToolCall(0, "c1", "read", `{"file":"a.txt"}`), pFinish("tool_calls")), + pOK(pContent("Deux lignes."), pFinish("stop"))), + extKeyFlag, "1"), extKeyURL, parityURL), extKeyModel, "gpt-x"), + + base("historique_avec_outils", []Message{sys, user("Lis a.txt"), + {Role: "assistant", ToolCalls: []ToolCall{{ID: "c0", Type: "function", Function: ToolCallFunc{Name: "read", Arguments: `{"file":"a.txt"}`}}}}, + {Role: "tool", ToolCallID: "c0", Content: "ligne 1"}, + {Role: "assistant", Content: "Une ligne."}, + user("Et b.txt ?")}, + pOK(pToolCall(0, "c1", "read", `{"file":"b.txt"}`), pFinish("tool_calls")), + pOK(pContent("Deux lignes."), pFinish("stop"))), + } +} + +// parityURL : remplacé par l'adresse du faux moteur au rejeu (côté Rust, +// l'adresse ne compte pas : l'hôte répond lui-même). +const parityURL = "{{URL}}" + +// with pose une clé de configuration sur le scénario. +func with(sc parityScenario, k, v string) parityScenario { + cfg := map[string]string{} + for a, b := range sc.Cfg { + cfg[a] = b + } + cfg[k] = v + sc.Cfg = cfg + return sc +} + +// --- Rejeu côté Go ------------------------------------------------------------- + +// parityEvent : forme comparable d'un événement (nil : ignoré — mesures, qui +// dépendent du temps). +func parityEvent(ev StreamEvent) map[string]any { + switch { + case ev.Content != "": + return map[string]any{"content": ev.Content} + case ev.Reasoning != "": + return map[string]any{"reasoning": ev.Reasoning} + case ev.ToolUsed != nil: + t := ev.ToolUsed + m := map[string]any{"name": t.Name} + put := func(k string, v any, keep bool) { + if keep { + m[k] = v + } + } + put("label", t.Label, t.Label != "") + put("result", t.Result, t.Result != "") + put("done", true, t.Done) + put("typing", true, t.Typing) + put("body", t.Body, t.Body != "") + put("body_tail", true, t.BodyTail) + put("body_lines", t.BodyLines, t.BodyLines != 0) + put("diff", t.Diff, len(t.Diff) > 0) + put("added", t.Added, t.Added != 0) + put("removed", t.Removed, t.Removed != 0) + put("image", t.Image, t.Image != "") + put("shot", t.Shot, t.Shot != "") + put("result_chars", t.ResultChars, t.ResultChars != 0) + put("result_id", t.ResultID, t.ResultID != "") + return map[string]any{"tool_used": m} + case ev.Ask != nil: + return map[string]any{"ask": ev.Ask} + case ev.Err != nil: + return map[string]any{"err": ev.Err.Error()} + case ev.DropReasoning: + return map[string]any{"drop_reasoning": true} + case ev.Compacting != nil: + return map[string]any{"compacting": *ev.Compacting} + case ev.NewHistory != nil: + return map[string]any{"new_history": ev.NewHistory} + case ev.Echo != nil: + return map[string]any{"echo": map[string]any{"text": ev.Echo.Text, "model": ev.Echo.Model}} + } + return nil +} + +// parityMerge regroupe les fragments consécutifs de texte : le découpage +// d'un flux en événements n'est pas un comportement. +func parityMerge(evs []map[string]any) []map[string]any { + var out []map[string]any + for _, e := range evs { + if n := len(out); n > 0 { + for _, k := range []string{"content", "reasoning"} { + a, ok1 := out[n-1][k].(string) + b, ok2 := e[k].(string) + if ok1 && ok2 && len(out[n-1]) == 1 && len(e) == 1 { + out[n-1] = map[string]any{k: a + b} + e = nil + break + } + } + } + if e != nil { + out = append(out, e) + } + } + return out +} + +func runParity(t *testing.T, sc parityScenario) parityExpect { + t.Helper() + testHome(t) + var ( + mu sync.Mutex + requests []any + next int + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.NotFound(w, r) + return + } + raw, _ := io.ReadAll(r.Body) + var body any + _ = json.Unmarshal(raw, &body) + mu.Lock() + requests = append(requests, body) + i := next + next++ + mu.Unlock() + if i >= len(sc.Replies) { + t.Errorf("%s : requête %d non scriptée", sc.Name, i+1) + http.Error(w, "non scripté", http.StatusTeapot) + return + } + rep := sc.Replies[i] + if rep.Status == 200 { + w.Header().Set("Content-Type", "text/event-stream") + } else { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(rep.Status) + var b strings.Builder + for _, l := range rep.Lines { + b.WriteString(l + "\n\n") + } + _, _ = w.Write([]byte(b.String())) + })) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + cfg := map[string]string{"PORT": u.Port()} + for k, v := range sc.Cfg { + cfg[k] = strings.ReplaceAll(v, parityURL, srv.URL) + } + if err := WriteConfig(cfg); err != nil { + t.Fatal(err) + } + execToolHook = func(_ context.Context, name string, _ map[string]any, _ string, _ Caps) toolExecResult { + if r, ok := sc.ToolResults[name]; ok { + return toolExecResult{Result: r} + } + return toolExecResult{Result: "[erreur] outil inconnu : " + name} + } + t.Cleanup(func() { execToolHook = nil }) + + var evs []map[string]any + msgs := append([]Message(nil), sc.Messages...) + extra, err := runChatTools(context.Background(), msgs, sc.Tools, sc.Temperature, Caps{}, func(ev StreamEvent) bool { + if e := parityEvent(ev); e != nil { + evs = append(evs, e) + } + return true + }) + exp := parityExpect{Requests: requests, Events: parityMerge(evs), Extra: extra} + if err != nil { + exp.Err = err.Error() + } + if exp.Requests == nil { + exp.Requests = []any{} + } + if exp.Events == nil { + exp.Events = []map[string]any{} + } + if exp.Extra == nil { + exp.Extra = []Message{} + } + return exp +} + +// parityJSON : JSON stable (clés triées par encoding/json), indenté. +func parityJSON(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func TestPariteJournaux(t *testing.T) { + update := os.Getenv("LOKI_PARITY_UPDATE") == "1" + seen := map[string]bool{} + for _, sc := range parityScenarios() { + t.Run(sc.Name, func(t *testing.T) { + if seen[sc.Name] { + t.Fatalf("scénario en double : %s", sc.Name) + } + seen[sc.Name] = true + fx := parityFixture{parityScenario: sc, Expect: runParity(t, sc)} + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(parityDir, sc.Name+".json") + if update { + if err := os.MkdirAll(parityDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("la boucle Go ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteJournaux,\n"+ + "puis cargo test --test parity dit si le port Rust suit.\n--- obtenu ---\n%s", path, got) + } + }) + } + if update { + return + } + // Pas de journal orphelin : un scénario retiré ici l'est aussi là-bas. + files, _ := filepath.Glob(filepath.Join(parityDir, "*.json")) + for _, f := range files { + if name := strings.TrimSuffix(filepath.Base(f), ".json"); !seen[name] { + t.Errorf("journal sans scénario : %s", f) + } + } +} diff --git a/internal/loki/parity_tools_test.go b/internal/loki/parity_tools_test.go new file mode 100644 index 0000000..c04b616 --- /dev/null +++ b/internal/loki/parity_tools_test.go @@ -0,0 +1,353 @@ +package loki + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + "time" +) + +// parity_tools_test.go — journal de référence des outils fichiers et du shell +// (lot 7 de la migration Rust). +// +// Une arborescence de test, une suite d'appels d'outils passés à execTool tels +// que la boucle les passe (libellé compris), dans l'ordre : leurs résultats, +// diffs et questions, puis l'arborescence finale, sont rangés dans +// rust/loki-rs/tests/parity_tools/outils.json. Le test Rust (tests/tools.rs) +// rejoue les mêmes appels dans tools::exec_local et doit retrouver ce journal. +// Chemins réécrits en {{ROOT}}, durées du shell en {{DUR}}. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils + +const ( + parityToolsFile = "../../rust/loki-rs/tests/parity_tools/outils.json" + parityToolsGitFile = "../../rust/loki-rs/tests/parity_tools/git.json" +) + +type parityFile struct { + Path string `json:"path"` + Content string `json:"content"` + // Âge en secondes (glob trie par date de modification). + Age int `json:"age"` +} + +type parityToolCase struct { + Name string `json:"name"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Code bool `json:"code,omitempty"` + // Unix : commande shell propre à bash. + Unix bool `json:"unix,omitempty"` + + Label string `json:"label"` + Result string `json:"result"` + Diff []DiffLine `json:"diff,omitempty"` + Added int `json:"added,omitempty"` + Removed int `json:"removed,omitempty"` + Ask *AskEvent `json:"ask,omitempty"` +} + +// parityGitCmd : une commande git de préparation, lancée dans Dir (relatif +// à {{ROOT}}) avant les appels. +type parityGitCmd struct { + Dir string `json:"dir"` + Args []string `json:"args"` +} + +type parityToolsFixture struct { + // Dossier de travail et dossier mémoire, relatifs à {{ROOT}}. + Cwd string `json:"cwd"` + MemoryDir string `json:"memory_dir"` + Files []parityFile `json:"files"` + GitSetup []parityGitCmd `json:"git_setup,omitempty"` + // Edits : fichiers écrits APRÈS la préparation git (ce que montreront + // status et diff). + Edits []parityFile `json:"edits,omitempty"` + Cases []parityToolCase `json:"cases"` + After map[string]string `json:"after"` +} + +func parityToolTree() []parityFile { + long := strings.Repeat("é", 600) + many := strings.Repeat("aiguille\n", 70) + return []parityFile{ + {Path: "ws/a.txt", Content: "ligne 1\nligne 2\nligne 3\n", Age: 50}, + {Path: "ws/b.txt", Content: "alpha\nbeta\n", Age: 40}, + {Path: "ws/vide.txt", Content: "", Age: 30}, + {Path: "ws/long.txt", Content: long + "\nfin\n", Age: 20}, + {Path: "ws/crlf.txt", Content: "un\r\ndeux\r\ntrois\r\n", Age: 10}, + {Path: "ws/bin.dat", Content: "PK\x00\x01binaire", Age: 60}, + {Path: "ws/src/main.go", Content: "package main\n\nfunc main() { x := 1 }\n", Age: 5}, + {Path: "ws/src/lib/util.go", Content: "package lib\n// x marque\n", Age: 3}, + {Path: "ws/src/lib/notes.md", Content: "x dans les notes\n", Age: 4}, + {Path: "ws/node_modules/dep/index.js", Content: "ligne cachée\n", Age: 70}, + {Path: "ws/beaucoup.txt", Content: many, Age: 80}, + {Path: "dehors.txt", Content: "hors du dossier\n", Age: 90}, + } +} + +func parityToolCases() []parityToolCase { + c := func(name, tool string, args map[string]any) parityToolCase { + return parityToolCase{Name: name, Tool: tool, Args: args} + } + code := func(p parityToolCase) parityToolCase { p.Code = true; return p } + unix := func(p parityToolCase) parityToolCase { p.Unix = true; return p } + return []parityToolCase{ + c("lecture", "read", map[string]any{"file": "a.txt"}), + c("lecture_fenetre", "read", map[string]any{"file": "a.txt", "offset": 2, "limit": 1}), + c("lecture_fenetre_decimale", "read", map[string]any{"file": "a.txt", "offset": 2.7, "limit": "3"}), + c("lecture_chemin_vide", "read", map[string]any{"file": " "}), + c("lecture_absent", "read", map[string]any{"file": "absent.txt"}), + c("lecture_dossier", "read", map[string]any{"file": "src"}), + c("lecture_binaire", "read", map[string]any{"file": "bin.dat"}), + c("lecture_au_dela", "read", map[string]any{"file": "a.txt", "offset": 99}), + c("lecture_ligne_longue", "read", map[string]any{"file": "long.txt"}), + c("lecture_fichier_vide", "read", map[string]any{"file": "vide.txt"}), + c("lecture_absolue", "read", map[string]any{"file": "{{ROOT}}/dehors.txt"}), + c("lecture_relative_sortante", "read", map[string]any{"file": "src/../../dehors.txt"}), + code(c("lecture_code_hors_dossier", "read", map[string]any{"file": "../dehors.txt"})), + code(c("lecture_code", "read", map[string]any{"file": "b.txt"})), + + c("grep", "grep", map[string]any{"pattern": "ligne [12]"}), + c("grep_filtre", "grep", map[string]any{"pattern": "x", "glob": "*.go"}), + c("grep_filtre_classe", "grep", map[string]any{"pattern": "x", "glob": "[mn]*"}), + c("grep_fichier", "grep", map[string]any{"pattern": "beta", "path": "b.txt"}), + c("grep_sous_dossier", "grep", map[string]any{"pattern": "package", "path": "src/lib"}), + c("grep_point", "grep", map[string]any{"pattern": "alpha", "path": "."}), + c("grep_aucun", "grep", map[string]any{"pattern": "zzz"}), + c("grep_motif_vide", "grep", map[string]any{"pattern": " "}), + c("grep_plafond", "grep", map[string]any{"pattern": "aiguille"}), + c("grep_absent", "grep", map[string]any{"pattern": "a", "path": "nulle-part"}), + code(c("grep_code_hors_dossier", "grep", map[string]any{"pattern": "a", "path": "{{ROOT}}"})), + + c("glob_go", "glob", map[string]any{"pattern": "**/*.go"}), + c("glob_racine", "glob", map[string]any{"pattern": "*.txt"}), + c("glob_point", "glob", map[string]any{"pattern": "./src/*"}), + c("glob_aucun", "glob", map[string]any{"pattern": "*.rs"}), + c("glob_vide", "glob", map[string]any{"pattern": ""}), + + c("question", "ask", map[string]any{"question": "Lequel ?", "options": []any{"a", " ", "b", "c", "d", "e"}}), + c("question_vide", "ask", map[string]any{"question": ""}), + + c("ecriture_neuve", "write", map[string]any{"file": "neuf/n.txt", "content": "un\ndeux\n"}), + c("ecriture_existant", "write", map[string]any{"file": "b.txt", "content": "remplacé\n"}), + c("ecriture_memoire", "write", map[string]any{"file": "{{MEM}}/x.md", "content": "x\n"}), + c("ecriture_chemin_vide", "write", map[string]any{"file": "", "content": "x"}), + code(c("ecriture_code_non_lu", "write", map[string]any{"file": "src/lib/notes.md", "content": "x\n"})), + code(c("ecriture_code_hors_dossier", "write", map[string]any{"file": "../evade.txt", "content": "x\n"})), + code(c("ecriture_code_neuve", "write", map[string]any{"file": "code.txt", "content": "c\n"})), + + c("edition", "edit", map[string]any{"file": "a.txt", "old": "ligne 2", "new": "ligne deux"}), + c("edition_deja_faite", "edit", map[string]any{"file": "a.txt", "old": "ligne 2", "new": "ligne deux"}), + c("edition_introuvable", "edit", map[string]any{"file": "a.txt", "old": "zzz", "new": "y"}), + c("edition_ambigue", "edit", map[string]any{"file": "a.txt", "old": "ligne", "new": "l"}), + c("edition_crlf", "edit", map[string]any{"file": "crlf.txt", "old": "deux\ntrois", "new": "2\n3"}), + c("edition_old_vide", "edit", map[string]any{"file": "a.txt", "old": "", "new": "y"}), + c("edition_fichier_absent", "edit", map[string]any{"file": "absent.txt", "old": "a", "new": "b"}), + code(c("edition_code_non_lu", "edit", map[string]any{"file": "src/main.go", "old": "x := 1", "new": "x := 2"})), + code(c("edition_code_apres_lecture", "edit", map[string]any{"file": "b.txt", "old": "remplacé", "new": "rem"})), + + unix(c("shell", "bash", map[string]any{"command": "echo bonjour"})), + unix(c("shell_sortie_et_erreurs", "bash", map[string]any{"command": "echo out; echo err >&2; exit 3"})), + unix(c("shell_tail", "bash", map[string]any{"command": "printf 'a\\nb\\nc\\n' | tail -2"})), + unix(c("shell_ansi", "bash", map[string]any{"command": "printf '\\033[31mrouge\\033[0m\\n'"})), + unix(c("shell_dossier", "bash", map[string]any{"command": "ls src"})), + unix(c("shell_longue_sortie", "bash", map[string]any{"command": "seq 1 3000"})), + unix(c("shell_delai", "bash", map[string]any{"command": "echo avant; sleep 3", "timeout": 1})), + c("shell_dangereux", "bash", map[string]any{"command": "rm -rf /"}), + unix(c("shell_memoire", "bash", map[string]any{"command": "cat {{MEM}}/x.md"})), + unix(code(c("shell_code_arriere_plan", "bash", map[string]any{"command": "sleep 1 &"}))), + } +} + +// subst remplace {{ROOT}} et {{MEM}} dans les chaînes des arguments. +func paritySubst(v any, root, mem string) any { + switch x := v.(type) { + case string: + return strings.NewReplacer("{{MEM}}", mem, "{{ROOT}}", root).Replace(x) + case map[string]any: + out := map[string]any{} + for k, e := range x { + out[k] = paritySubst(e, root, mem) + } + return out + case []any: + out := make([]any, len(x)) + for i, e := range x { + out[i] = paritySubst(e, root, mem) + } + return out + } + return v +} + +var parityDurRe = regexp.MustCompile(`(exit: -?\d+) · [0-9.]+[a-zµ]+(?:[0-9.]+[a-zµ]+)*`) + +func parityNorm(s, root string) string { + s = strings.ReplaceAll(s, root, "{{ROOT}}") + return parityDurRe.ReplaceAllString(s, "$1 · {{DUR}}") +} + +func TestPariteOutils(t *testing.T) { + runParityTools(t, parityToolsFile, parityToolsFixture{Files: parityToolTree(), Cases: parityToolCases()}) +} + +// Outils git (mode Code) : deux dépôts, l'un avec un commit et des +// modifications, l'autre vide ; aucun appel ne sort sur le réseau. +func TestPariteOutilsGit(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git absent") + } + // Ni configuration globale ni système : sorties de git identiques partout. + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + id := []string{"-c", "user.name=Loki", "-c", "user.email=loki@test"} + c := func(name, tool string, args map[string]any) parityToolCase { + return parityToolCase{Name: name, Tool: tool, Args: args} + } + fx := parityToolsFixture{ + Files: []parityFile{ + {Path: "ws/depot/a.txt", Content: "un\ndeux\ntrois\n", Age: 30}, + {Path: "ws/depot/b.txt", Content: "bêta\n", Age: 30}, + {Path: "ws/vide/x.txt", Content: "x\n", Age: 30}, + }, + GitSetup: []parityGitCmd{ + {Dir: "ws/depot", Args: []string{"init", "-q", "-b", "main"}}, + {Dir: "ws/depot", Args: []string{"add", "a.txt", "b.txt"}}, + {Dir: "ws/depot", Args: append(append([]string{}, id...), "commit", "-q", "-m", "départ")}, + {Dir: "ws/vide", Args: []string{"init", "-q", "-b", "main"}}, + }, + Edits: []parityFile{ + {Path: "ws/depot/a.txt", Content: "un\n2\ntrois\n", Age: 10}, + {Path: "ws/depot/neuf.txt", Content: "neuf\n", Age: 10}, + }, + Cases: []parityToolCase{ + c("statut_ambigu", "git_status", map[string]any{}), + c("statut", "git_status", map[string]any{"dir": "depot"}), + c("diff", "git_diff", map[string]any{"dir": "depot"}), + c("diff_fichier", "git_diff", map[string]any{"dir": "depot", "file": "b.txt"}), + c("diff_sans_commit", "git_diff", map[string]any{"dir": "vide"}), + c("statut_sans_commit", "git_status", map[string]any{"dir": "vide"}), + c("dossier_sortant", "git_status", map[string]any{"dir": "../x"}), + c("dossier_absent", "git_status", map[string]any{"dir": "nulle-part"}), + c("clone_url_locale", "git_clone", map[string]any{"url": "file:///etc"}), + c("clone_commande", "git_clone", map[string]any{"url": "ext::sh -c id"}), + c("clone_existant", "git_clone", map[string]any{"url": "https://exemple.invalid/u/depot.git"}), + c("clone_dossier_sortant", "git_clone", map[string]any{"url": "git@exemple.invalid:u/r", "dir": "../r"}), + }, + } + runParityTools(t, parityToolsGitFile, fx) +} + +func runParityTools(t *testing.T, file string, fx parityToolsFixture) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("journal tiré sous Unix") + } + root := testHome(t) + if r, err := filepath.EvalSymlinks(root); err == nil { + root = r // macOS : /var → /private/var, comme le verraient les outils + } + t.Setenv("LOKI_HOME", root) + ws := filepath.Join(root, "ws") + now := time.Now() + for _, f := range fx.Files { + p := filepath.Join(root, filepath.FromSlash(f.Path)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(f.Content), 0o644); err != nil { + t.Fatal(err) + } + mt := now.Add(-time.Duration(f.Age) * time.Second) + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } + for _, g := range fx.GitSetup { + cmd := exec.Command("git", g.Args...) + cmd.Dir = filepath.Join(root, filepath.FromSlash(g.Dir)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v : %v\n%s", g.Args, err, out) + } + } + for _, f := range fx.Edits { + p := filepath.Join(root, filepath.FromSlash(f.Path)) + if err := os.WriteFile(p, []byte(f.Content), 0o644); err != nil { + t.Fatal(err) + } + mt := now.Add(-time.Duration(f.Age) * time.Second) + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } + setTaskWorkspace(ws) + t.Cleanup(func() { setTaskWorkspace("") }) + mem := memoryDir() + relMem, err := filepath.Rel(root, mem) + if err != nil || strings.HasPrefix(relMem, "..") { + t.Fatalf("dossier mémoire hors de la racine : %s", mem) + } + + fx.Cwd, fx.MemoryDir = "ws", filepath.ToSlash(relMem) + cases := fx.Cases + fx.Cases = nil + for _, pc := range cases { + args := paritySubst(pc.Args, root, mem).(map[string]any) + // Les nombres passent par JSON, comme ceux d'un vrai appel. + b, _ := json.Marshal(args) + args = map[string]any{} + _ = json.Unmarshal(b, &args) + label := toolCallLabel(pc.Tool, args) + res := execTool(context.Background(), pc.Tool, args, label, Caps{Code: pc.Code}) + pc.Label = parityNorm(label, root) + pc.Result = parityNorm(res.Result, root) + pc.Diff, pc.Added, pc.Removed, pc.Ask = res.Diff, res.Added, res.Removed, res.Ask + fx.Cases = append(fx.Cases, pc) + } + fx.After = map[string]string{} + _ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, _ := filepath.Rel(root, p) + rel = filepath.ToSlash(rel) + if !strings.HasPrefix(rel, "ws/") && rel != "dehors.txt" && rel != "evade.txt" { + return nil // base, configuration : propres au Go + } + if strings.Contains(rel, "/.git/") { + return nil // index, objets : datés + } + b, _ := os.ReadFile(p) + fx.After[rel] = string(b) + return nil + }) + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(file) + 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("les outils ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils,\n"+ + "puis cargo test --test tools dit si le port Rust suit.\n--- obtenu ---\n%s", file, got) + } +} diff --git a/internal/loki/parity_turn_test.go b/internal/loki/parity_turn_test.go new file mode 100644 index 0000000..9a83a36 --- /dev/null +++ b/internal/loki/parity_turn_test.go @@ -0,0 +1,299 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" +) + +// parity_turn_test.go — journaux de référence d'un TOUR COMPLET, tel que le +// voit l'interface (lot 6 de la migration Rust). +// +// Chaque scénario passe par la vraie route /api/chat/send du Go, contre un +// faux moteur : le journal d'affichage du tour (deltas, une fois le tour fini +// et coalescé), l'historique rangé et les corps envoyés au moteur sont rangés +// dans rust/loki-rs/tests/parity_turn/.json, avec ce que la façade +// demandera au Go pendant le même tour (réponses de /internal/v1/turn/start +// et /turn/view). Le test Rust (tests/lot6.rs, parite_du_tour_complet) rejoue +// le tour par chat::generate et doit retrouver ces journaux. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTourComplet + +const parityTurnDir = "../../rust/loki-rs/tests/parity_turn" + +type parityTurnScenario struct { + Name string `json:"name"` + Cfg map[string]string `json:"cfg"` + // Agent : mode agent allumé (réglage de la machine, clé d'état « agent »). + Agent bool `json:"agent,omitempty"` + // Code : discussion en mode Code, avec ces critères d'acceptation. + Code bool `json:"code,omitempty"` + Criteria []Criterion `json:"criteria,omitempty"` + Send map[string]any `json:"send"` + Replies []parityReply `json:"replies"` + ToolResults map[string]string `json:"tool_results"` +} + +type parityTurnExpect struct { + Requests []any `json:"requests"` + Log []map[string]any `json:"log"` + Messages []Message `json:"messages"` + Status int `json:"status"` +} + +type parityTurnFixture struct { + parityTurnScenario + // Réponses du Go à la façade pendant ce tour. + Start any `json:"start"` + View any `json:"view"` + Verifier any `json:"verifier,omitempty"` + Expect parityTurnExpect `json:"expect"` +} + +func parityTurnScenarios() []parityTurnScenario { + send := func(msg string) map[string]any { return map[string]any{"message": msg} } + base := func(name string, s map[string]any, replies ...parityReply) parityTurnScenario { + s["cid"] = "c-" + name // un envoi par scénario : jamais pris pour un réessai + return parityTurnScenario{ + Name: name, Cfg: map[string]string{"MEM_MODE": "off"}, Send: s, Replies: replies, + ToolResults: map[string]string{"bash": "a.txt\nb.txt", "read": "1: ligne"}, + } + } + agent := func(sc parityTurnScenario) parityTurnScenario { + sc.Agent = true + return sc + } + code := func(sc parityTurnScenario, crit ...Criterion) parityTurnScenario { + sc.Agent, sc.Code, sc.Criteria = true, true, crit + return sc + } + return []parityTurnScenario{ + base("reponse_simple", send("Bonjour"), + pOK(pContent("Bon"), pContent("jour !"), pFinish("stop"))), + base("raisonnement", send("Combien ?"), + pOK(pReasoning("Je compte."), pContent("Quatre."), pFinish("stop"))), + agent(base("outil_puis_reponse", send("Liste les fichiers"), + pOK(pToolCall(0, "c1", "bash", `{"command":"ls"}`), pFinish("tool_calls")), + pOK(pContent("Deux fichiers."), pFinish("stop")))), + agent(base("pense_sans_agir", send("Cherche"), + pOK(pReasoning("Il faudrait lister."), pFinish("stop")), + pOK(pContent("Voilà."), pFinish("stop")))), + code(base("code_contrat_deja_tenu", send("Corrige le test"), + pOK(pContent("Corrigé."), pFinish("stop"))), + Criterion{ID: 1, Text: "les tests passent", Status: "passed"}), + code(base("code_contrat_abandonne", send("Construis le jeu"), + pOK(pContent("r0"), pFinish("stop")), pOK(pContent("r1"), pFinish("stop")), + pOK(pContent("r2"), pFinish("stop")), pOK(pContent("v1"), pFinish("stop")), + pOK(pContent("r3"), pFinish("stop")), pOK(pContent("v2"), pFinish("stop")), + pOK(pContent("r4"), pFinish("stop")), pOK(pContent("v3"), pFinish("stop"))), + Criterion{ID: 1, Text: "le build passe", Status: "pending"}), + base("erreur_moteur", send("Bonjour"), + parityReply{Status: 400, Lines: []string{`{"error":{"code":400,"message":"requête invalide","type":"invalid_request_error"}}`}}), + } +} + +// parityTurnLog : le journal du tour, mesures et horaires masqués. +func parityTurnLog(evs []LogEvent) []map[string]any { + out := make([]map[string]any, 0, len(evs)) + for _, e := range evs { + d := map[string]any{} + for k, v := range e.Delta { + switch k { + case "stats": + v = "…" + case "ts0": + continue // horodatage du premier fragment coalescé + } + d[k] = v + } + out = append(out, d) + } + return out +} + +func runParityTurn(t *testing.T, sc parityTurnScenario) parityTurnFixture { + t.Helper() + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + // Playwright installé ou non selon la machine : web_screenshot entrerait ou + // non dans les outils du mode Code. Hors du PATH, le journal ne dépend plus + // de la machine qui le tire. + for { + p, err := exec.LookPath("playwright") + if err != nil { + break + } + var keep []string + for _, d := range filepath.SplitList(os.Getenv("PATH")) { + if filepath.Clean(d) != filepath.Dir(p) { + keep = append(keep, d) + } + } + t.Setenv("PATH", strings.Join(keep, string(os.PathListSeparator))) + } + var ( + mu sync.Mutex + requests []any + next int + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + io.WriteString(w, `{"status":"ok"}`) + return + } + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.NotFound(w, r) + return + } + raw, _ := io.ReadAll(r.Body) + var body any + _ = json.Unmarshal(raw, &body) + mu.Lock() + requests = append(requests, body) + i := next + next++ + mu.Unlock() + if i >= len(sc.Replies) { + t.Errorf("%s : requête %d non scriptée", sc.Name, i+1) + http.Error(w, "non scripté", http.StatusTeapot) + return + } + rep := sc.Replies[i] + if rep.Status == 200 { + w.Header().Set("Content-Type", "text/event-stream") + } + w.WriteHeader(rep.Status) + var b strings.Builder + for _, l := range rep.Lines { + b.WriteString(l + "\n\n") + } + io.WriteString(w, b.String()) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + cfg := map[string]string{"PORT": u.Port()} + for k, v := range sc.Cfg { + cfg[k] = v + } + if err := WriteConfig(cfg); err != nil { + t.Fatal(err) + } + if err := setAgentEnabled(sc.Agent); err != nil { + t.Fatal(err) + } + execToolHook = func(_ context.Context, name string, _ map[string]any, _ string, _ Caps) toolExecResult { + if r, ok := sc.ToolResults[name]; ok { + return toolExecResult{Result: r} + } + return toolExecResult{Result: "[erreur] outil inconnu : " + name} + } + t.Cleanup(func() { execToolHook = nil }) + + conv.Reset() + if sc.Code { + id := convEnsureActive() + if err := setConvMode(id, "code"); err != nil { + t.Fatal(err) + } + critSave(id, sc.Criteria) + } + body, _ := json.Marshal(sc.Send) + // Ce que la façade obtiendra du Go pour ce même envoi. + start := turnJSON(t, "/internal/v1/turn/start", string(body), 200) + caps, _ := json.Marshal(map[string]any{"caps": start["turn_caps"]}) + view := turnJSON(t, "/internal/v1/turn/view", string(caps), 200) + var verifier any + if sc.Code { + verifier = turnJSON(t, "/internal/v1/turn/verifier", string(caps), 200) + } + + w := httptest.NewRecorder() + handleChatSend(w, httptest.NewRequest("POST", "/api/chat/send", bytes.NewReader(body))) + deadline := time.Now().Add(10 * time.Second) + for { + conv.mu.Lock() + done := !conv.Generating + conv.mu.Unlock() + if done || time.Now().After(deadline) { + break + } + time.Sleep(10 * time.Millisecond) + } + persistQ.flush() + conv.mu.Lock() + exp := parityTurnExpect{ + Requests: requests, + Log: parityTurnLog(conv.Log), + Messages: append([]Message{}, conv.Messages...), + Status: w.Code, + } + conv.mu.Unlock() + if exp.Requests == nil { + exp.Requests = []any{} + } + return parityTurnFixture{parityTurnScenario: sc, Start: start, View: view, Verifier: verifier, Expect: exp} +} + +// parityMasks : ce que le prompt de la machine porte de propre à CE lancement +// (dossiers temporaires, discussion, date, hôte). La façade reçoit ce prompt +// tel quel par /turn/view : masqué ici, il l'est des deux côtés. +var parityMasks = []struct { + re *regexp.Regexp + with string +}{ + {regexp.MustCompile(`(/private)?` + regexp.QuoteMeta(filepath.Clean(os.TempDir())) + `/Test[^/"\\]*/[0-9]+`), "{{TMP}}"}, + {regexp.MustCompile(`\bc[0-9]{15,}\b`), "{{CONV}}"}, + {regexp.MustCompile(`Date: [0-9]{4}-[0-9]{2}-[0-9]{2}`), "Date: {{DATE}}"}, + {regexp.MustCompile(`Machine: host=[^,]*, [a-z0-9]+/[a-z0-9]+(, user=[^,.]*)?`), "Machine: host={{HOST}}"}, +} + +func parityMask(b []byte) []byte { + for _, m := range parityMasks { + b = m.re.ReplaceAll(b, []byte(m.with)) + } + return b +} + +func TestPariteTourComplet(t *testing.T) { + update := os.Getenv("LOKI_PARITY_UPDATE") == "1" + for _, sc := range parityTurnScenarios() { + t.Run(sc.Name, func(t *testing.T) { + got, err := parityJSON(runParityTurn(t, sc)) + if err != nil { + t.Fatal(err) + } + got = parityMask(got) + path := filepath.Join(parityTurnDir, sc.Name+".json") + if update { + if err := os.MkdirAll(parityTurnDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("le tour Go ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTourComplet,\n"+ + "puis cargo test --test lot6 parite_du_tour_complet dit si le port Rust suit.\n--- obtenu ---\n%s", path, got) + } + }) + } +} diff --git a/internal/loki/projects.go b/internal/loki/projects.go index c92a375..bf50fba 100644 --- a/internal/loki/projects.go +++ b/internal/loki/projects.go @@ -360,9 +360,23 @@ func deleteProject(slug string) error { if !found { return fmt.Errorf("projet introuvable") } - // Discussions du projet. - for _, m := range convIndexForProject(slug) { - _ = convDelete(m.ID) + // Discussions du projet. Quand la façade tient la conversation, c'est + // elle qui les supprime — après avoir quitté le projet s'il est actif : + // elle enregistre sa discussion et en ouvre une du projet restant, et ne + // supprime donc jamais celle qu'elle a ouverte. + if facadeChat() { + if activeProjectSlug() == slug { + if err := facadeProjectSwitch(kept[0].Slug); err != nil { + return err + } + } + if err := facadeDropProject(slug); err != nil { + return err + } + } else { + for _, m := range convIndexForProject(slug) { + _ = convDelete(m.ID) + } } // Trackers du projet. for _, k := range trackerKeysForProject(slug) { diff --git a/internal/loki/relay_e2e.go b/internal/loki/relay_e2e.go index 75dabee..267b3cc 100644 --- a/internal/loki/relay_e2e.go +++ b/internal/loki/relay_e2e.go @@ -174,7 +174,13 @@ func handleE2EChat(w http.ResponseWriter, r *http.Request) { sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...) return []byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n"), true } - runChatStream(r.Context(), body, &sseStream{w: w, flusher: flusher, mu: mu, frame: frame}) + out := &sseStream{w: w, flusher: flusher, mu: mu, frame: frame} + // Conversation tenue par la façade : c'est SON journal qu'on suit. + if facadeChat() { + facadeChatStream(r.Context(), plain, out) + return + } + runChatStream(r.Context(), body, out) } // handleE2EReq : proxy de CONTRÔLE chiffré de bout en bout. Même enveloppe que le diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index f874c44..67427e3 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2407,7 +2407,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Nouvelle discussion
- + @@ -5246,7 +5246,7 @@ async function loadAll(){ // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // suppression de module doit donc retirer son appel de cette ligne, et le test // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } @@ -10863,17 +10863,18 @@ function openDetails(el){ } } -// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient -// à cliquer la ligne correspondante dans les réglages (switchTo demande +// Sélecteur de modèle de l'en-tête. Il ne liste QUE des presets ; en choisir un +// revient à cliquer la ligne correspondante dans les réglages (switchTo demande // confirmation puis relance le moteur). +// +// Il a listé un temps les .gguf du disque, qu'on chargeait en n'écrivant que +// MODEL= : le contexte, le NGL et l'échantillonnage du modèle PRÉCÉDENT +// restaient en place. Un 27B chargé par-dessus les réglages d'un 4B partait sur +// 100k de contexte et mourait en OOM, sans que rien ne dise pourquoi. Charger un +// modèle, c'est appliquer TOUS ses réglages : ça s'appelle un preset, et c'est +// le seul chemin. function onModelSwitch(sel){ const v = sel.value; - // Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config - // active garde son contexte et son échantillonnage, seul MODEL change. - if(v && v.startsWith('m:')){ - switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent); - return; - } const n = parseInt(v, 10); if(!n) return; const name = sel.options[sel.selectedIndex].textContent; @@ -10883,22 +10884,6 @@ function onModelSwitch(sel){ switchTo(n, name, sel.options[sel.selectedIndex].dataset.id); } -// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme, -// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection -// revient sur le modèle réellement chargé. -async function switchModelFile(value, name){ - if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){ - paintModelSwitch(); - return; - } - toast('changement de modèle…'); - let r = null; - try{ r = await jpost('/api/models/use', {value}); }catch(_){} - if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; } - toast('modèle en cours de chargement…'); - loadAll(); -} - // Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif. // // Les deux ne coïncident pas toujours : un preset n'est « actif » que si son @@ -10911,23 +10896,12 @@ async function switchModelFile(value, name){ // Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le // modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent // en parallèle (loadAll), aucun ordre n'est garanti. -let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = []; +let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = ''; function renderModelSwitch(presets, active){ MS_PRESETS = presets || []; MS_ACTIVE = active || null; paintModelSwitch(); } -// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de -// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé, -// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste. -async function loadModelSwitchFiles(){ - try{ - const r = await jget('/api/models'); - // Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas. - MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||''))); - }catch(_){ return; } - paintModelSwitch(); -} // Appelé par loadCfg avec la valeur MODEL de la configuration active. function setLoadedModel(name){ MS_MODEL = (name || '').trim(); @@ -10966,20 +10940,7 @@ function paintModelSwitch(){ }); sel.appendChild(g); } - // Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un - // preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL. - if(MS_FILES.length){ - const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)'; - for(const m of MS_FILES){ - const o = document.createElement('option'); - o.value = 'm:' + m.value; - o.textContent = modelLabel(m.name); - if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes - g.appendChild(o); - } - sel.appendChild(g); - } - sel.disabled = !MS_PRESETS.length && !MS_FILES.length; + sel.disabled = !MS_PRESETS.length; sel.title = MS_MODEL ? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur' : 'aucun modèle configuré'; diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index bc78e6d..e405561 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -122,7 +122,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
Nouvelle discussion
- + diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js index 273789d..5990abb 100644 --- a/internal/loki/ui/src/js/06-settings.js +++ b/internal/loki/ui/src/js/06-settings.js @@ -673,7 +673,7 @@ async function loadAll(){ // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // suppression de module doit donc retirer son appel de cette ligne, et le test // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/loki/ui/src/js/18-shell.js b/internal/loki/ui/src/js/18-shell.js index 5c931ce..b60fdb2 100644 --- a/internal/loki/ui/src/js/18-shell.js +++ b/internal/loki/ui/src/js/18-shell.js @@ -48,17 +48,18 @@ function openDetails(el){ } } -// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient -// à cliquer la ligne correspondante dans les réglages (switchTo demande +// Sélecteur de modèle de l'en-tête. Il ne liste QUE des presets ; en choisir un +// revient à cliquer la ligne correspondante dans les réglages (switchTo demande // confirmation puis relance le moteur). +// +// Il a listé un temps les .gguf du disque, qu'on chargeait en n'écrivant que +// MODEL= : le contexte, le NGL et l'échantillonnage du modèle PRÉCÉDENT +// restaient en place. Un 27B chargé par-dessus les réglages d'un 4B partait sur +// 100k de contexte et mourait en OOM, sans que rien ne dise pourquoi. Charger un +// modèle, c'est appliquer TOUS ses réglages : ça s'appelle un preset, et c'est +// le seul chemin. function onModelSwitch(sel){ const v = sel.value; - // Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config - // active garde son contexte et son échantillonnage, seul MODEL change. - if(v && v.startsWith('m:')){ - switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent); - return; - } const n = parseInt(v, 10); if(!n) return; const name = sel.options[sel.selectedIndex].textContent; @@ -68,22 +69,6 @@ function onModelSwitch(sel){ switchTo(n, name, sel.options[sel.selectedIndex].dataset.id); } -// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme, -// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection -// revient sur le modèle réellement chargé. -async function switchModelFile(value, name){ - if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){ - paintModelSwitch(); - return; - } - toast('changement de modèle…'); - let r = null; - try{ r = await jpost('/api/models/use', {value}); }catch(_){} - if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; } - toast('modèle en cours de chargement…'); - loadAll(); -} - // Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif. // // Les deux ne coïncident pas toujours : un preset n'est « actif » que si son @@ -96,23 +81,12 @@ async function switchModelFile(value, name){ // Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le // modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent // en parallèle (loadAll), aucun ordre n'est garanti. -let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = []; +let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = ''; function renderModelSwitch(presets, active){ MS_PRESETS = presets || []; MS_ACTIVE = active || null; paintModelSwitch(); } -// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de -// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé, -// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste. -async function loadModelSwitchFiles(){ - try{ - const r = await jget('/api/models'); - // Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas. - MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||''))); - }catch(_){ return; } - paintModelSwitch(); -} // Appelé par loadCfg avec la valeur MODEL de la configuration active. function setLoadedModel(name){ MS_MODEL = (name || '').trim(); @@ -151,20 +125,7 @@ function paintModelSwitch(){ }); sel.appendChild(g); } - // Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un - // preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL. - if(MS_FILES.length){ - const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)'; - for(const m of MS_FILES){ - const o = document.createElement('option'); - o.value = 'm:' + m.value; - o.textContent = modelLabel(m.name); - if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes - g.appendChild(o); - } - sel.appendChild(g); - } - sel.disabled = !MS_PRESETS.length && !MS_FILES.length; + sel.disabled = !MS_PRESETS.length; sel.title = MS_MODEL ? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur' : 'aucun modèle configuré'; diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index fafdf6e..f08b0ac 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -945,6 +945,7 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) { // qu'une version précédente chiffrait par erreur (voir plainStoreKey). healPlainStoreKeys() reloadEncryptedStores() // recharge la discussion chiffrée en RAM + facadeReload() // … ou celle de la façade, si c'est elle qui la tient // Migration douce : chiffre ce qui serait resté en clair (discussions, // blocs archivés, trackers). Idempotent — n'encode que ce qui ne l'est pas. if memEncActive() && memUnlocked() { @@ -1376,41 +1377,6 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true}) } -// handleModelUse charge un MODÈLE précis (fichier .gguf) sans passer par un -// preset : seul MODEL change dans la configuration active — contexte, NGL et -// échantillonnage sont conservés — et le service redémarre en arrière-plan, -// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête -// utilisable même sans avoir créé de preset. -func handleModelUse(w http.ResponseWriter, r *http.Request) { - if tuneDenyHTTP(w) { - return - } - var req struct { - Value string `json:"value"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Value) == "" { - sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle manquant"}) - return - } - // Le fichier doit exister AVANT d'écrire la config : une faute de frappe ne - // doit pas laisser un moteur qui boucle sur un modèle introuvable. - if _, err := resolveServeModelPath(req.Value); err != nil { - sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) - return - } - if err := SetConfigKey("MODEL", req.Value); err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - fmt.Printf("%s MODEL <- %s\n", green("[ok]"), req.Value) - go func() { - if err := serviceAction("restart"); err != nil { - fmt.Println(red("[err] redémarrage : " + err.Error())) - } - }() - sendJSON(w, 200, map[string]any{"ok": true}) -} - func handleSwitch(w http.ResponseWriter, r *http.Request) { // Pendant une optimisation, la configuration active est celle qu'elle mesure // et le moteur ne doit pas redémarrer (backend_tune_lock.go). diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 3391d6b..5fad81b 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -71,10 +71,42 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalOAIEnd(w, r) case "/internal/v1/tool/exec": handleInternalToolExec(w, r) + case "/internal/v1/tool/context": + handleInternalToolContext(w, r) + case "/internal/v1/tool/tracker": + handleInternalToolTracker(w, r) + case "/internal/v1/tool/wrote": + handleInternalToolWrote(w, r) case "/internal/v1/crypt/seal": handleInternalCrypt(w, r, true) case "/internal/v1/crypt/open": handleInternalCrypt(w, r, false) + case "/internal/v1/engine/lease": + handleInternalLease(w, r) + case "/internal/v1/engine/lease/renew": + handleInternalLeaseRenew(w, r) + case "/internal/v1/engine/lease/release": + handleInternalLeaseRelease(w, r) + case "/internal/v1/engine/stop": + handleInternalEngineStop(w, r) + case "/internal/v1/recall/archive": + handleInternalRecallArchive(w, r) + case "/internal/v1/turn/start": + handleInternalTurnStart(w, r) + case "/internal/v1/turn/view": + handleInternalTurnView(w, r) + case "/internal/v1/turn/done": + handleInternalTurnDone(w, r) + case "/internal/v1/turn/verifier": + handleInternalTurnVerifier(w, r) + case "/internal/v1/project/activate": + handleInternalProjectActivate(w, r) + case "/internal/v1/chat/holder": + handleInternalChatHolder(w, r) + case "/internal/v1/conv/active": + handleInternalConvActive(w, r) + case "/internal/v1/conv/dropped": + handleInternalConvDropped(w, r) default: http.NotFound(w, r) } diff --git a/internal/loki/web_internal_lease.go b/internal/loki/web_internal_lease.go new file mode 100644 index 0000000..ee1eb1c --- /dev/null +++ b/internal/loki/web_internal_lease.go @@ -0,0 +1,196 @@ +package loki + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// API interne du bail du moteur (lot 6 de la migration Rust). +// +// Le verrou de génération (conv.Generating) est partagé : tour de chat, tâche +// planifiée (RunAutonomous), bench et optimiseur (measureLease), et quatre +// sondes isGenerating (optimiseur, VRAM, projets, liste des discussions). Une +// conversation tenue par la façade Rust doit prendre CE verrou-là, sans quoi +// une tâche planifiée partirait en plein tour vers le même moteur. +// +// POST /internal/v1/engine/lease {"ttl_ms":N} → 200 {"lease":id,"ttl_ms":N} +// 409 {"error":…,"task_id":…,"task_name":…} +// POST /internal/v1/engine/lease/renew {"lease":id} → 200 {"stop":bool} · 410 bail perdu +// POST /internal/v1/engine/lease/release {"lease":id} → 204 (idempotent) +// POST /internal/v1/engine/stop → 204 : arrête le détenteur Go +// (tâche, bench, optimiseur) +// +// Le bail expire s'il n'est pas renouvelé : une façade tuée en plein tour ne +// laisse pas le moteur réservé pour toujours. Reset et les bascules de +// discussion côté Go le rompent (renew → 410), comme ils libèrent un tour Go. +// « stop » : le bouton stop est passé par le Go (conv.Stop) — la façade arrête +// son tour. + +const ( + extLeaseDefaultTTL = 30 * time.Second + extLeaseMinTTL = 5 * time.Second + extLeaseMaxTTL = 5 * time.Minute + internalLeaseMax = 4 << 10 +) + +// extLeaseState : le bail tenu par la façade. Protégé par conv.mu. +type extLeaseState struct { + id string + stop bool + ttl time.Duration + timer *time.Timer +} + +// extLeaseClearLocked oublie le bail de la façade. c.mu détenu. +func (c *Conversation) extLeaseClearLocked() { + if c.extLease != nil { + c.extLease.timer.Stop() + c.extLease = nil + } +} + +// extLeaseAcquire prend le verrou de génération pour la façade. +func (c *Conversation) extLeaseAcquire(ttl time.Duration) (string, error) { + if err := tuneGuard(); err != nil { + return "", err + } + c.mu.Lock() + defer c.mu.Unlock() + if c.Generating { + return "", ErrBusy + } + b, err := randBytes(16) + if err != nil { + return "", err + } + st := &extLeaseState{id: fmt.Sprintf("%x", b), ttl: ttl} + st.timer = time.AfterFunc(ttl, func() { c.extLeaseExpire(st) }) + c.Generating = true + c.cancel = func() { + c.mu.Lock() + st.stop = true + c.mu.Unlock() + } + c.extLease = st + return st.id, nil +} + +// extLeaseExpire rend le verrou d'un bail que la façade n'a pas renouvelé. +func (c *Conversation) extLeaseExpire(st *extLeaseState) { + c.mu.Lock() + defer c.mu.Unlock() + if c.extLease != st { + return + } + c.extLease = nil + c.Generating, c.cancel = false, nil + fmt.Fprintf(os.Stderr, "[lease] bail de la façade expiré sans renouvellement (%s) : verrou du moteur rendu\n", st.ttl) +} + +// extLeaseRenew prolonge le bail ; ok=false s'il n'est plus le bail courant. +func (c *Conversation) extLeaseRenew(id string) (stop, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + st := c.extLease + if st == nil || st.id != id { + return false, false + } + st.timer.Reset(st.ttl) + return st.stop, true +} + +// extLeaseRelease rend le verrou, si ce bail le tient encore. +func (c *Conversation) extLeaseRelease(id string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.extLease == nil || c.extLease.id != id { + return + } + c.extLeaseClearLocked() + c.Generating, c.cancel = false, nil +} + +func readLeaseBody(w http.ResponseWriter, r *http.Request, v any) bool { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return false + } + raw, err := io.ReadAll(io.LimitReader(r.Body, internalLeaseMax+1)) + if err != nil || len(raw) > internalLeaseMax { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "corps illisible"}) + return false + } + if len(raw) > 0 && json.Unmarshal(raw, v) != nil { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "JSON illisible"}) + return false + } + return true +} + +func handleInternalLease(w http.ResponseWriter, r *http.Request) { + var in struct { + TTLms int64 `json:"ttl_ms"` + } + if !readLeaseBody(w, r, &in) { + return + } + ttl := extLeaseDefaultTTL + if in.TTLms > 0 { + ttl = min(max(time.Duration(in.TTLms)*time.Millisecond, extLeaseMinTTL), extLeaseMaxTTL) + } + id, err := conv.extLeaseAcquire(ttl) + if err != nil { + if err == ErrBusy { + err = conv.busyReason() // dit QUI occupe le moteur + } + out := map[string]any{"error": err.Error()} + conv.mu.Lock() + if conv.runningTaskID != "" { + out["task_id"], out["task_name"] = conv.runningTaskID, conv.runningTaskName + } + conv.mu.Unlock() + sendJSON(w, http.StatusConflict, out) + return + } + sendJSON(w, http.StatusOK, map[string]any{"lease": id, "ttl_ms": ttl.Milliseconds()}) +} + +func handleInternalLeaseRenew(w http.ResponseWriter, r *http.Request) { + var in struct { + Lease string `json:"lease"` + } + if !readLeaseBody(w, r, &in) { + return + } + stop, ok := conv.extLeaseRenew(in.Lease) + if !ok { + sendJSON(w, http.StatusGone, map[string]any{"error": "bail perdu"}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"stop": stop}) +} + +func handleInternalLeaseRelease(w http.ResponseWriter, r *http.Request) { + var in struct { + Lease string `json:"lease"` + } + if !readLeaseBody(w, r, &in) { + return + } + conv.extLeaseRelease(in.Lease) + w.WriteHeader(http.StatusNoContent) +} + +func handleInternalEngineStop(w http.ResponseWriter, r *http.Request) { + var in struct{} + if !readLeaseBody(w, r, &in) { + return + } + conv.Stop() + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/loki/web_internal_lease_test.go b/internal/loki/web_internal_lease_test.go new file mode 100644 index 0000000..1d0c442 --- /dev/null +++ b/internal/loki/web_internal_lease_test.go @@ -0,0 +1,153 @@ +package loki + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +func leaseJSON(t *testing.T, body string) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(body), &m); err != nil { + t.Fatalf("réponse illisible %q : %v", body, err) + } + return m +} + +// libérerVerrou rend le verrou de génération quoi qu'un test ait laissé. +func libérerVerrou(t *testing.T) { + t.Cleanup(func() { + conv.mu.Lock() + conv.extLeaseClearLocked() + conv.Generating, conv.benching, conv.cancel, conv.leaseWhy = false, false, nil, nil + conv.mu.Unlock() + }) +} + +// Le bail de la façade EST le verrou de génération : tant qu'il tient, une +// mesure (bench, optimiseur) ou un second bail est refusé. +func TestBailInterneVerrouPartage(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + libérerVerrou(t) + + w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease", `{"ttl_ms":60000}`) + if w.Code != 200 { + t.Fatalf("bail : %d %s", w.Code, w.Body.String()) + } + m := leaseJSON(t, w.Body.String()) + id, _ := m["lease"].(string) + if len(id) != 32 || m["ttl_ms"] != float64(60000) { + t.Fatalf("bail : %v", m) + } + if !conv.isGenerating() { + t.Fatal("le bail ne tient pas le verrou de génération") + } + if _, err := conv.measureLease(func() {}, errBenchBusy); err != ErrBusy { + t.Fatalf("bench accepté pendant le bail : %v", err) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease", ""); w.Code != 409 || + !strings.Contains(w.Body.String(), ErrBusy.Error()) { + t.Fatalf("second bail : %d %s", w.Code, w.Body.String()) + } + + // Renouvelé ; le stop passé par le Go remonte à la façade. + w = appelOAIInterne(t, "POST", "/internal/v1/engine/lease/renew", `{"lease":"`+id+`"}`) + if w.Code != 200 || leaseJSON(t, w.Body.String())["stop"] != false { + t.Fatalf("renouvellement : %d %s", w.Code, w.Body.String()) + } + conv.Stop() + w = appelOAIInterne(t, "POST", "/internal/v1/engine/lease/renew", `{"lease":"`+id+`"}`) + if w.Code != 200 || leaseJSON(t, w.Body.String())["stop"] != true { + t.Fatalf("stop non transmis : %d %s", w.Code, w.Body.String()) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease/renew", `{"lease":"autre"}`); w.Code != 410 { + t.Fatalf("bail inconnu renouvelé : %d", w.Code) + } + + // Rendu : le verrou est libre, le bail est mort, rendre deux fois est sans effet. + for i := 0; i < 2; i++ { + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease/release", `{"lease":"`+id+`"}`); w.Code != 204 { + t.Fatalf("libération : %d", w.Code) + } + } + if conv.isGenerating() { + t.Fatal("verrou toujours tenu après la libération") + } + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease/renew", `{"lease":"`+id+`"}`); w.Code != 410 { + t.Fatalf("bail rendu renouvelé : %d", w.Code) + } +} + +// Un bail rendu après coup ne libère pas le verrou d'un autre détenteur. +func TestBailInterneNeLibereQueLeSien(t *testing.T) { + testHome(t) + libérerVerrou(t) + id, err := conv.extLeaseAcquire(time.Minute) + if err != nil { + t.Fatal(err) + } + conv.Reset() // rompt le bail, comme un tour Go + if _, ok := conv.extLeaseRenew(id); ok { + t.Fatal("bail encore valide après Reset") + } + release, err := conv.measureLease(func() {}, errBenchBusy) + if err != nil { + t.Fatalf("verrou non rendu par Reset : %v", err) + } + defer release() + conv.extLeaseRelease(id) + if !conv.isGenerating() { + t.Fatal("un bail périmé a libéré le verrou du bench") + } +} + +// Une façade tuée en plein tour ne garde pas le moteur : sans renouvellement, +// le bail expire et rend le verrou. +func TestBailInterneExpire(t *testing.T) { + testHome(t) + libérerVerrou(t) + id, err := conv.extLeaseAcquire(50 * time.Millisecond) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for conv.isGenerating() { + if time.Now().After(deadline) { + t.Fatal("bail jamais expiré") + } + time.Sleep(10 * time.Millisecond) + } + if _, ok := conv.extLeaseRenew(id); ok { + t.Fatal("bail expiré encore renouvelable") + } +} + +// Moteur tenu par le Go : le refus dit qui le tient, et /engine/stop l'arrête. +func TestBailInterneRefusEtStop(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + libérerVerrou(t) + ctx, cancel := context.WithCancel(context.Background()) + release, err := conv.measureLease(cancel, errBenchBusy) + if err != nil { + t.Fatal(err) + } + defer release() + w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease", "") + if w.Code != 409 || leaseJSON(t, w.Body.String())["error"] != errBenchBusy.Error() { + t.Fatalf("refus : %d %s", w.Code, w.Body.String()) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/stop", ""); w.Code != 204 { + t.Fatalf("stop : %d", w.Code) + } + if ctx.Err() == nil { + t.Fatal("le bench n'a pas été arrêté") + } + if w := appelOAIInterne(t, "GET", "/internal/v1/engine/stop", ""); w.Code != 405 { + t.Fatalf("GET accepté : %d", w.Code) + } +} diff --git a/internal/loki/web_internal_recall.go b/internal/loki/web_internal_recall.go new file mode 100644 index 0000000..a811693 --- /dev/null +++ b/internal/loki/web_internal_recall.go @@ -0,0 +1,48 @@ +package loki + +import ( + "encoding/json" + "errors" + "io" + "net/http" +) + +// API interne de l'archive recall (lot 6 de la migration Rust). +// +// La compaction range VERBATIM les gros blocs du torse qu'elle résume, pour +// que l'outil recall puisse les rendre plus tard. Le numéro d'un bloc vient de +// la séquence bbolt du bucket : seul ce process peut le donner. Une +// compaction faite par la façade fait donc archiver ici. +// +// POST /internal/v1/recall/archive {"label":…,"role":…,"content":…} +// → 200 {"id":"r7"} · 423 mémoire verrouillée (rien d'écrit en clair) + +const internalRecallMaxBody = 32 << 20 + +func handleInternalRecallArchive(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return + } + var in struct { + Label string `json:"label"` + Role string `json:"role"` + Content string `json:"content"` + } + raw, err := io.ReadAll(io.LimitReader(r.Body, internalRecallMaxBody+1)) + if err != nil || len(raw) > internalRecallMaxBody || json.Unmarshal(raw, &in) != nil || in.Content == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "bloc illisible"}) + return + } + id, err := archiveRecallBlock(in.Label, in.Role, in.Content) + if errors.Is(err, errMemLocked) { + sendJSON(w, http.StatusLocked, map[string]any{"error": err.Error()}) + return + } + if err != nil { + sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"id": id}) +} diff --git a/internal/loki/web_internal_recall_test.go b/internal/loki/web_internal_recall_test.go new file mode 100644 index 0000000..63ec8cd --- /dev/null +++ b/internal/loki/web_internal_recall_test.go @@ -0,0 +1,29 @@ +package loki + +import ( + "strings" + "testing" +) + +// Un bloc archivé par la façade se relit par l'outil recall, comme s'il avait +// été archivé par la compaction du Go. +func TestRecallInterneArchive(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + w := appelOAIInterne(t, "POST", "/internal/v1/recall/archive", `{"label":"bash ls","role":"tool","content":"a.go\nb.go"}`) + if w.Code != 200 { + t.Fatalf("archive : %d %s", w.Code, w.Body.String()) + } + id, _ := leaseJSON(t, w.Body.String())["id"].(string) + if !strings.HasPrefix(id, "r") { + t.Fatalf("id : %q", id) + } + if got := toolRecall(map[string]any{"id": id}); !strings.Contains(got, "a.go\nb.go") || !strings.Contains(got, "bash ls") { + t.Fatalf("recall : %q", got) + } + for _, body := range []string{"", "{", `{"label":"x"}`} { + if w := appelOAIInterne(t, "POST", "/internal/v1/recall/archive", body); w.Code != 400 { + t.Fatalf("%q : %d", body, w.Code) + } + } +} diff --git a/internal/loki/web_internal_tool.go b/internal/loki/web_internal_tool.go index e4fafb9..edaae25 100644 --- a/internal/loki/web_internal_tool.go +++ b/internal/loki/web_internal_tool.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "strings" ) // API interne d'exécution des outils (lot 6 de la migration Rust). @@ -16,7 +17,14 @@ import ( // // POST /internal/v1/tool/exec // {"name":"bash","args":{"command":"ls"},"caps":{"Agent":true,"Code":false,…}} -// → {"label":"ls","result":"…","diff":[…],"added":N,"removed":N,"vision_img":{…},"ask":{…}} +// → {"label":"ls","result":"…","diff":[…],"added":N,"removed":N,"vision_img":{…},"ask":{…}, +// "image":"…","shot":"…","relay":{…}} +// +// image, shot et relay sont ce que la boucle Go tire du résultat APRÈS +// l'exécution, et qui demande le disque ou le navigateur (toolStepExtras) : +// la capture à montrer dans la bulle, l'aperçu en direct du navigateur piloté, +// et le message `user` qui porte l'image au modèle (capture, see_image) — +// éphémère, à ne jamais ranger dans l'historique. // // Le contexte de la requête porte l'arrêt : si l'appelant coupe la connexion // (bouton stop), la commande en cours est interrompue comme dans le Go. @@ -46,8 +54,35 @@ func handleInternalToolExec(w http.ResponseWriter, r *http.Request) { } label := toolCallLabel(in.Name, in.Args) res := execTool(r.Context(), in.Name, in.Args, label, in.Caps) + image, shot, relay := toolStepExtras(in.Name, label, res) sendJSON(w, http.StatusOK, struct { Label string `json:"label"` toolExecResult - }{label, res}) + Image string `json:"image,omitempty"` + Shot string `json:"shot,omitempty"` + Relay *Message `json:"relay,omitempty"` + }{label, res, image, shot, relay}) +} + +// toolStepExtras : ce que runChatLoop tire d'un résultat d'outil après +// l'exécution — capture de web_screenshot pour la bulle, aperçu du navigateur +// après une action qui change la page (jamais après une erreur), image relayée +// au modèle (capture avec la vision active, ou see_image réussi). +func toolStepExtras(name, label string, res toolExecResult) (image, shot string, relay *Message) { + if !strings.HasPrefix(res.Result, "[erreur]") && cuAutoShotTool(name) { + shot = cuAutoShot() + } + if name == "web_screenshot" { + image = capturedRelPath(res.Result) + if image != "" { + if m, ok := screenshotImageMessage(image); ok { + relay = &m + } + } + } + if res.VisionImg != nil { + m := seeImageMessage(label, res.VisionImg) + relay = &m + } + return image, shot, relay } diff --git a/internal/loki/web_internal_tool_test.go b/internal/loki/web_internal_tool_test.go index cd58edc..a7efc80 100644 --- a/internal/loki/web_internal_tool_test.go +++ b/internal/loki/web_internal_tool_test.go @@ -60,3 +60,27 @@ func TestOutilInterneAppelIllisible(t *testing.T) { t.Fatalf("GET : %d", w.Code) } } + +// Ce que la boucle tire du résultat après l'exécution : capture de la bulle, +// image relayée au modèle — rien après une erreur. +func TestOutilInterneSuitesDuResultat(t *testing.T) { + testHome(t) + image, shot, relay := toolStepExtras("web_screenshot", "", toolExecResult{ + Result: "Capture : ![page](/api/chat/image?path=captures/a.png)", + }) + if image != "captures/a.png" || shot != "" || relay != nil { + t.Fatalf("capture : %q %q %v (vision coupée : pas de relais)", image, shot, relay) + } + img := map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AA=="}} + _, _, relay = toolStepExtras("see_image", "photo.png", toolExecResult{Result: "ok", VisionImg: img}) + if relay == nil || relay.Role != "user" { + t.Fatalf("see_image : %v", relay) + } + b, _ := json.Marshal(relay) + if !strings.Contains(string(b), "Image demandée (photo.png)") || !strings.Contains(string(b), "data:image/png") { + t.Fatalf("relais : %s", b) + } + if _, shot, _ := toolStepExtras("browser_click", "x", toolExecResult{Result: "[erreur] rien"}); shot != "" { + t.Fatalf("aperçu après une erreur : %q", shot) + } +} diff --git a/internal/loki/web_internal_toolstate.go b/internal/loki/web_internal_toolstate.go new file mode 100644 index 0000000..dbe75a2 --- /dev/null +++ b/internal/loki/web_internal_toolstate.go @@ -0,0 +1,78 @@ +package loki + +import ( + "net/http" + "strings" +) + +// API interne de l'état des outils (lot 7 de la migration Rust). +// +// La façade exécute elle-même les outils fichiers et le shell (read, grep, +// glob, write, edit, bash, ask) quand elle tient la conversation. Ce qu'ils +// partagent avec le reste de Loki reste ici, en un seul exemplaire : le +// dossier de travail de la discussion, le dossier mémoire interdit au shell, +// la cible distante, le suivi « lu avant d'écrire » (lu aussi par les +// sous-agents et les tâches, qui tournent ici) et les diagnostics LSP. +// +// POST /internal/v1/tool/context {} +// → {"cwd":"…","memory_dir":"…","target":"slug|"} +// POST /internal/v1/tool/tracker {"op":"read","path":"/abs"} → {} +// POST /internal/v1/tool/tracker {"op":"check","path":"/abs","for_write":true} +// → {"refusal":"[refusé] …"|""} +// POST /internal/v1/tool/wrote {"path":"/abs","caps":{…}} +// → {"lsp":"\n\n[diagnostics LSP] …"|""} +// +// wrote : après une écriture réussie (write/edit) — note du tracker, puis le +// bloc des diagnostics LSP que la boucle ajoute au résultat de l'outil. + +func handleInternalToolContext(w http.ResponseWriter, r *http.Request) { + var in struct{} + if !readInternalJSON(w, r, &in) { + return + } + sendJSON(w, http.StatusOK, map[string]any{ + "cwd": agentCwd(), + "memory_dir": memoryDir(), + "target": agentTargetSlug(), + }) +} + +func handleInternalToolTracker(w http.ResponseWriter, r *http.Request) { + var in struct { + Op string `json:"op"` + Path string `json:"path"` + ForWrite bool `json:"for_write"` + } + if !readInternalJSON(w, r, &in) { + return + } + if strings.TrimSpace(in.Path) == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "chemin manquant"}) + return + } + switch in.Op { + case "read": + trackerNoteRead(in.Path) + sendJSON(w, http.StatusOK, map[string]any{}) + case "check": + sendJSON(w, http.StatusOK, map[string]any{"refusal": trackerCheck(in.Path, in.ForWrite)}) + default: + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "op inconnue"}) + } +} + +func handleInternalToolWrote(w http.ResponseWriter, r *http.Request) { + var in struct { + Path string `json:"path"` + Caps Caps `json:"caps"` + } + if !readInternalJSON(w, r, &in) { + return + } + if strings.TrimSpace(in.Path) == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "chemin manquant"}) + return + } + trackerNoteWrite(in.Path) + sendJSON(w, http.StatusOK, map[string]any{"lsp": lspDiagBlock(in.Path, in.Caps)}) +} diff --git a/internal/loki/web_internal_toolstate_test.go b/internal/loki/web_internal_toolstate_test.go new file mode 100644 index 0000000..5c8f1ce --- /dev/null +++ b/internal/loki/web_internal_toolstate_test.go @@ -0,0 +1,74 @@ +package loki + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Le contexte des outils dit à la façade où travaille l'agent, ce que le shell +// n'a pas le droit de toucher, et s'il vise un poste distant. +func TestOutilsInterneContexte(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + w := appelOAIInterne(t, "POST", "/internal/v1/tool/context", `{}`) + if w.Code != 200 { + t.Fatalf("contexte : %d %s", w.Code, w.Body.String()) + } + got := leaseJSON(t, w.Body.String()) + if got["cwd"] != agentCwd() || got["memory_dir"] != memoryDir() || got["target"] != "" { + t.Fatalf("contexte : %v", got) + } + if w := appelOAIInterne(t, "GET", "/internal/v1/tool/context", ``); w.Code != 405 { + t.Fatalf("GET : %d", w.Code) + } +} + +// Une lecture notée par la façade autorise l'écriture, exactement comme une +// lecture faite ici ; une écriture notée aussi. +func TestOutilsInterneTracker(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + f := filepath.Join(t.TempDir(), "a.txt") + if err := os.WriteFile(f, []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + check := func() string { + t.Helper() + w := appelOAIInterne(t, "POST", "/internal/v1/tool/tracker", `{"op":"check","path":`+jsonQuote(f)+`,"for_write":true}`) + if w.Code != 200 { + t.Fatalf("check : %d %s", w.Code, w.Body.String()) + } + r, _ := leaseJSON(t, w.Body.String())["refusal"].(string) + return r + } + if r := check(); !strings.Contains(r, "pas lu") { + t.Fatalf("fichier jamais lu accepté : %q", r) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/tool/tracker", `{"op":"read","path":`+jsonQuote(f)+`}`); w.Code != 200 { + t.Fatalf("read : %d", w.Code) + } + if r := check(); r != "" { + t.Fatalf("lecture notée mais refus : %q", r) + } + g := filepath.Join(filepath.Dir(f), "b.txt") + if err := os.WriteFile(g, []byte("y\n"), 0o644); err != nil { + t.Fatal(err) + } + w := appelOAIInterne(t, "POST", "/internal/v1/tool/wrote", `{"path":`+jsonQuote(g)+`,"caps":{"Code":true}}`) + if w.Code != 200 { + t.Fatalf("wrote : %d %s", w.Code, w.Body.String()) + } + if trackerCheck(g, false) != "" { + t.Fatal("écriture notée mais edit refusé") + } + for _, body := range []string{"", "{", `{"op":"read"}`, `{"op":"x","path":"/a"}`} { + if w := appelOAIInterne(t, "POST", "/internal/v1/tool/tracker", body); w.Code != 400 { + t.Fatalf("%q : %d", body, w.Code) + } + } + if w := appelOAIInterne(t, "POST", "/internal/v1/tool/wrote", `{}`); w.Code != 400 { + t.Fatalf("wrote sans chemin : %d", w.Code) + } +} diff --git a/internal/loki/web_internal_turn.go b/internal/loki/web_internal_turn.go new file mode 100644 index 0000000..99bbc47 --- /dev/null +++ b/internal/loki/web_internal_turn.go @@ -0,0 +1,217 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" +) + +// API interne d'un tour de discussion tenu par la façade Rust (lot 6). +// +// La boucle, le journal et la persistance sont en Rust ; ce qui décrit le +// tour reste ici, le temps d'être porté (lot 7) : pièces jointes et vision, +// rôle du mode Code, prompt du preset, consignes du dépôt, contexte du +// projet, préambule d'agent, outils (MCP compris), puce « mode Code », +// notification Web Push, et l'état par discussion que les outils lisent. +// +// POST /internal/v1/turn/start corps de /api/chat/send +// → {files, caps, turn_caps, content, model, code_hint} +// POST /internal/v1/turn/view {"caps":{…}} → {head, prefix, tools} +// 409 : PROJ_SNAPSHOT actif (le bloc projet figé reste au Go) +// POST /internal/v1/turn/done {"ms":N} → 204 (Web Push si abonnés) +// POST /internal/v1/project/activate {"slug":…} → 204 (projet actif changé +// par la façade, qui a enregistré la discussion du projet quitté) +// POST /internal/v1/turn/verifier {"caps":{…}} → {system, tools, caps} +// (passe de vérification du mode Code : contexte ISOLÉ, rôle verifier) +// POST /internal/v1/conv/active {"id":…} → 204 (discussion active des outils) +// POST /internal/v1/conv/dropped {"id":…} → 204 (fichiers, jobs, critères, +// mode, résultats d'outils ; +// "files_only" : les fichiers) + +const internalTurnMaxBody = 8 << 20 + +func readInternalJSON(w http.ResponseWriter, r *http.Request, v any) bool { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return false + } + raw, err := io.ReadAll(io.LimitReader(r.Body, internalTurnMaxBody+1)) + if err != nil || len(raw) > internalTurnMaxBody || json.Unmarshal(raw, v) != nil { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "corps illisible"}) + return false + } + return true +} + +// handleInternalTurnStart : ce que StartTurn et EnqueueOrStart tirent de la +// requête avant de lancer (ou de mettre en file) le tour. caps : capacités +// demandées (file d'attente, préchauffage) ; turn_caps : celles du tour, rôle +// du mode Code figé ; content : le message utilisateur vu par le modèle. +func handleInternalTurnStart(w http.ResponseWriter, r *http.Request) { + var body chatReq + if !readInternalJSON(w, r, &body) { + return + } + files := attachFiles(body.Files) + if strings.TrimSpace(body.Message) == "" && len(files) == 0 { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "message vide"}) + return + } + rememberUserTZ(body.TZ) + caps := capsFromBody(body) + turnCaps := withTurnRole(caps, body.Message) + prompt := body.Message + if strings.TrimSpace(prompt) == "" { + prompt = "Prends-en connaissance." + } + if files == nil { + files = []attachInfo{} + } + sendJSON(w, http.StatusOK, map[string]any{ + "files": files, + "caps": caps, + "turn_caps": turnCaps, + "content": userMessageContent(files, prompt), + "model": chatModelName(), + "code_hint": !turnCaps.Code && codeHintDue(body.Message), + }) +} + +// handleInternalTurnView : les pièces de turnView (assembleTurn sans +// PROJ_SNAPSHOT) que la façade assemble autour de l'historique : head part +// devant les messages, puis prefix (InjectSkills) fusionné dans le premier +// système qui n'est pas un message projet, ou posé en tête. +func handleInternalTurnView(w http.ResponseWriter, r *http.Request) { + var in struct { + Caps Caps `json:"caps"` + } + if !readInternalJSON(w, r, &in) { + return + } + caps := in.Caps + if caps.Agent && projSnapEnabled(ReadConfig()) { + sendJSON(w, http.StatusConflict, map[string]any{"error": "PROJ_SNAPSHOT actif : le bloc projet figé reste tenu par le Go"}) + return + } + head := []Message{} + if caps.Agent { + if sp := readSysPrompt(); sp != "" { + head = append(head, Message{Role: "system", Content: sp}) + } + if m, ok := codeInstructionsMessage(caps); ok { + head = append(head, m) + } + head = append(head, projectSystemMessages()...) + } + tools := EnabledTools(caps) + if tools == nil { + tools = []Tool{} + } + sendJSON(w, http.StatusOK, map[string]any{"head": head, "prefix": injectPrefix(caps, tools), "tools": tools}) +} + +// handleInternalTurnVerifier : ce que runVerifyPass envoie au moteur hors de +// l'historique — le prompt du rôle verifier suivi des faits de la machine, et +// le jeu d'outils fermé de la vérification. caps : celles du tour, rôle +// remplacé (le Go s'y fie pour laisser l'outil criteria marquer « passed »). +func handleInternalTurnVerifier(w http.ResponseWriter, r *http.Request) { + var in struct { + Caps Caps `json:"caps"` + } + if !readInternalJSON(w, r, &in) { + return + } + vcaps := in.Caps + vcaps.Role = "verifier" + sendJSON(w, http.StatusOK, map[string]any{ + "system": rolePrompt("verifier") + "\n\n" + machineSystemPrompt(vcaps), + "tools": EnabledTools(vcaps), + "caps": vcaps, + }) +} + +// handleInternalProjectActivate : setActiveProject pour la façade (projet +// rangé en base, cache de ceux qui le lisent ici — mémoire, trackers, outils). +func handleInternalProjectActivate(w http.ResponseWriter, r *http.Request) { + var in struct { + Slug string `json:"slug"` + } + if !readInternalJSON(w, r, &in) { + return + } + if !projectExists(in.Slug) { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "projet introuvable"}) + return + } + if err := setActiveProject(in.Slug); err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func handleInternalTurnDone(w http.ResponseWriter, r *http.Request) { + var in struct { + Ms int64 `json:"ms"` + } + if !readInternalJSON(w, r, &in) { + return + } + if hasPushSubs() { + d := time.Duration(in.Ms) * time.Millisecond + go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(d)) + } + w.WriteHeader(http.StatusNoContent) +} + +// handleInternalConvActive : la façade a changé de discussion active. Les +// outils exécutés ici (dossier de la discussion, mode Code, critères) lisent +// l'identifiant en cache : il suit. +func handleInternalConvActive(w http.ResponseWriter, r *http.Request) { + var in struct { + ID string `json:"id"` + } + if !readInternalJSON(w, r, &in) { + return + } + if strings.TrimSpace(in.ID) == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "id manquant"}) + return + } + convActiveMu.Lock() + convActiveRemember(in.ID) + convActiveMu.Unlock() + w.WriteHeader(http.StatusNoContent) +} + +// handleInternalConvDropped : ce qui part avec une discussion supprimée par +// la façade (mêmes effets que la fin de convDelete). +func handleInternalConvDropped(w http.ResponseWriter, r *http.Request) { + var in struct { + ID string `json:"id"` + // FilesOnly : seulement les fichiers (Reset vide la discussion, il ne + // la supprime pas). + FilesOnly bool `json:"files_only"` + } + if !readInternalJSON(w, r, &in) { + return + } + if strings.TrimSpace(in.ID) == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "id manquant"}) + return + } + if in.FilesOnly { + dropConvFiles(in.ID) + w.WriteHeader(http.StatusNoContent) + return + } + stopConvJobs(in.ID) + critDrop(in.ID) + dropConvMode(in.ID) + dropConvFiles(in.ID) + deleteToolResultsFor(in.ID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/loki/web_internal_turn_test.go b/internal/loki/web_internal_turn_test.go new file mode 100644 index 0000000..092d036 --- /dev/null +++ b/internal/loki/web_internal_turn_test.go @@ -0,0 +1,160 @@ +package loki + +import ( + "encoding/json" + "strings" + "testing" +) + +func turnJSON(t *testing.T, path, body string, want int) map[string]any { + t.Helper() + w := appelOAIInterne(t, "POST", path, body) + if w.Code != want { + t.Fatalf("%s : %d %s", path, w.Code, w.Body.String()) + } + var m map[string]any + if w.Body.Len() > 0 { + if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil { + t.Fatal(err) + } + } + return m +} + +// Début d'un tour : message vu par le modèle, modèle, capacités du tour, +// puce « mode Code » une seule fois par discussion. +func TestTourInterneDebut(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + turnJSON(t, "/internal/v1/turn/start", `{"message":" "}`, 400) + if err := SetConfigKey("MODEL", "/data/models/Qwen3-8B.gguf"); err != nil { + t.Fatal(err) + } + got := turnJSON(t, "/internal/v1/turn/start", `{"message":"salut"}`, 200) + if got["content"] != "salut" || got["model"] != "Qwen3-8B" || got["code_hint"] != false { + t.Fatalf("début : %v", got) + } + if files, _ := got["files"].([]any); files == nil || len(files) != 0 { + t.Fatalf("files : %v", got["files"]) + } + if c, _ := got["caps"].(map[string]any); c["Agent"] != false { + t.Fatalf("caps sans agent : %v", c) + } + + // Agent actif : un message qui ressemble à du code fait apparaître la puce, + // une seule fois. + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + got = turnJSON(t, "/internal/v1/turn/start", "{\"message\":\"corrige main.go\"}", 200) + if got["code_hint"] != true { + t.Fatalf("puce attendue : %v", got) + } + if got = turnJSON(t, "/internal/v1/turn/start", "{\"message\":\"corrige main.go\"}", 200); got["code_hint"] != false { + t.Fatalf("puce montrée deux fois : %v", got) + } + + // Mode Code : rôle du tour figé (plan demandé → planner). + if err := setConvMode(convEnsureActive(), "code"); err != nil { + t.Fatal(err) + } + got = turnJSON(t, "/internal/v1/turn/start", `{"message":"fais un plan"}`, 200) + tc, _ := got["turn_caps"].(map[string]any) + c, _ := got["caps"].(map[string]any) + if tc["Code"] != true || tc["Role"] == "" || c["Role"] != "" { + t.Fatalf("rôle du tour : caps=%v turn_caps=%v", c, tc) + } +} + +// Vue d'un tour : rien du projet sans agent ; tête et préambule avec ; refus +// sous PROJ_SNAPSHOT. +func TestTourInterneVue(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + got := turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":false,"Mem":"off"}}`, 200) + if h, _ := got["head"].([]any); len(h) != 0 { + t.Fatalf("sans agent, rien du projet : %v", got["head"]) + } + off := Caps{Mem: MemOff} + if got["prefix"] != injectPrefix(off, EnabledTools(off)) { + t.Fatalf("préambule sans agent : %v", got["prefix"]) + } + if err := saveSysPrompt("Réponds en vers."); err != nil { + t.Fatal(err) + } + got = turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true,"Mem":"off"}}`, 200) + tools, _ := got["tools"].([]any) + prefix, _ := got["prefix"].(string) + head, _ := got["head"].([]any) + if len(tools) == 0 || prefix == "" || len(head) == 0 { + t.Fatalf("agent : %d outils, préambule %q, tête %v", len(tools), prefix, head) + } + if h0, _ := head[0].(map[string]any); h0["content"] != "Réponds en vers." { + t.Fatalf("prompt du preset en tête : %v", head[0]) + } + // Même préambule que celui que pose InjectSkills. + var caps Caps + _ = json.Unmarshal([]byte(`{"Agent":true,"Mem":"off"}`), &caps) + if want := injectPrefix(caps, EnabledTools(caps)); prefix != want { + t.Fatal("préambule différent de celui d'InjectSkills") + } + if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil { + t.Fatal(err) + } + turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true}}`, 409) + turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":false,"Mem":"off"}}`, 200) +} + +// État par discussion : l'active suit la façade ; une discussion supprimée +// emporte son mode ; fin de tour sans abonnés push. +func TestTourInterneDiscussions(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + turnJSON(t, "/internal/v1/conv/active", `{"id":"c99"}`, 204) + if got := convActiveCached(); got != "c99" { + t.Fatalf("active en cache : %q", got) + } + turnJSON(t, "/internal/v1/conv/active", `{"id":" "}`, 400) + if err := setConvMode("c99", "code"); err != nil { + t.Fatal(err) + } + turnJSON(t, "/internal/v1/conv/dropped", `{"id":"c99"}`, 204) + if getStr(bkChat, modeKey("c99")) != "" { + t.Fatal("mode de la discussion supprimée resté en base") + } + turnJSON(t, "/internal/v1/turn/done", `{"ms":1200}`, 204) + if w := appelOAIInterne(t, "POST", "/internal/v1/conv/dropped", "{"); w.Code != 400 || strings.Count(w.Body.String(), "error") != 1 { + t.Fatalf("corps illisible : %d %q", w.Code, w.Body.String()) + } +} + +// La passe de vérification de la façade reçoit exactement le contexte isolé +// de runVerifyPass : prompt du rôle et faits de la machine, outils fermés, +// rôle verifier (seul autorisé à marquer un critère « passed »). +func TestTourInterneVerificateur(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + got := turnJSON(t, "/internal/v1/turn/verifier", `{"caps":{"Agent":true,"Code":true,"Mem":"off","Role":"builder"}}`, 200) + vcaps := Caps{Agent: true, Code: true, Mem: MemOff, Role: "verifier"} + if got["system"] != rolePrompt("verifier")+"\n\n"+machineSystemPrompt(vcaps) { + t.Fatalf("système : %v", got["system"]) + } + caps, _ := got["caps"].(map[string]any) + if caps["Role"] != "verifier" || caps["Code"] != true { + t.Fatalf("caps : %v", caps) + } + tools, _ := got["tools"].([]any) + var names []string + for _, tl := range tools { + f, _ := tl.(map[string]any)["function"].(map[string]any) + names = append(names, f["name"].(string)) + } + var want []string + for _, tl := range EnabledTools(vcaps) { + want = append(want, tl.Function.Name) + } + if strings.Join(names, ",") != strings.Join(want, ",") || !strings.Contains(strings.Join(names, ","), "criteria") { + t.Fatalf("outils : %v, attendu %v", names, want) + } + turnJSON(t, "/internal/v1/turn/verifier", `{`, 400) +} diff --git a/internal/loki/web_projects.go b/internal/loki/web_projects.go index b02a224..11f9072 100644 --- a/internal/loki/web_projects.go +++ b/internal/loki/web_projects.go @@ -9,6 +9,7 @@ package loki import ( "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -89,7 +90,8 @@ func handleProjects(w http.ResponseWriter, r *http.Request) { err = setProjectDesc(slug, body.Desc) case "delete": // Une génération en cours travaille dans la mémoire du projet actif : - // supprimer sous ses pieds la laisserait écrire dans le vide. + // supprimer sous ses pieds la laisserait écrire dans le vide. La + // façade, quand elle tient la conversation, refuse elle-même (409). if conv.isGenerating() { sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"}) return @@ -100,12 +102,25 @@ func handleProjects(w http.ResponseWriter, r *http.Request) { sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"}) return } - err = projectSwitch(slug) + if facadeChat() { + if !projectExists(slug) { + err = fmt.Errorf("projet introuvable") + } else { + err = facadeProjectSwitch(slug) + } + } else { + err = projectSwitch(slug) + } default: err = fmt.Errorf("action inconnue : %q", body.Action) } if err != nil { - sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + code := 400 + var fe *facadeError + if errors.As(err, &fe) && fe.status == http.StatusConflict { + code = http.StatusConflict + } + sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()}) return } sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()}) diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index e137bea..4609ff9 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -217,7 +217,9 @@ func newWebMux() *http.ServeMux { // clé, celle des complétions (web_auth.go). mountOAI(mux) // api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go). - api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) } + // withFacadeChat : routes du chat remises à la façade quand elle tient la + // conversation (LOKI_CHAT=rust, facade_chat.go) ; sans effet sinon. + api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) } api("/api/ping", handlePing) api("/api/status", handleStatus) api("/api/transcribe", handleTranscribe) // dictée vocale (Parakeet via sherpa-onnx, local) @@ -241,7 +243,6 @@ func newWebMux() *http.ServeMux { api("/api/models", handleModels) api("/api/models/delete", handleModelDelete) api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…) - api("/api/models/use", handleModelUse) // charger CE .gguf (sélecteur de l'en-tête) api("/api/models/download", handleModelDownload) api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer api("/api/models/download/status", handleModelDownloadStatus) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f744208..9df7f87 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -667,6 +676,7 @@ dependencies = [ "hyper", "hyper-util", "libc", + "regex", "reqwest", "rustls", "serde", @@ -779,6 +789,35 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.13.5" diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml index d002ea7..fcf2da2 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -12,6 +12,7 @@ hex = "0.4.3" http-body-util = "0.1.5" hyper = { version = "1.12.0", features = ["http1", "server", "client"] } hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] } +regex = "1.13.1" reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "stream", "http2"] } rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"], optional = true } serde = { version = "1.0.229", features = ["derive"] } @@ -30,6 +31,7 @@ tls = ["dep:rustls"] libc = "0.2" [dev-dependencies] +tokio = { version = "1.53.2", features = ["test-util"] } futures-util = "0.3.34" tempfile = "3.27.0" tokio-tungstenite = "0.30.0" diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 7836ebc..020a999 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -24,6 +24,21 @@ pub struct Ctx { pub host_env: HostEnv, /// `$LOKI_HOME` et nom du service moteur (fichiers PID / journal). pub engine: engine::Paths, + /// Conversation tenue par la façade (`LOKI_CHAT=rust`) ; `None` : au Go. + pub chat: Option>, + /// Jeton de l'API interne : une requête qui le porte vient du Go. + pub internal_token: String, +} + +impl Ctx { + /// La requête vient du Go (jeton interne valide) : déjà authentifiée par + /// lui (tunnel du relais, redistribution E2E). + pub fn from_go(&self, headers: &HeaderMap) -> bool { + !self.internal_token.is_empty() + && headers.get(crate::auth::INTERNAL_HEADER).is_some_and(|v| { + auth::constant_time_eq(v.as_bytes(), self.internal_token.as_bytes()) + }) + } } impl Ctx { diff --git a/rust/loki-rs/src/auth.rs b/rust/loki-rs/src/auth.rs index b3fabb7..1beee73 100644 --- a/rust/loki-rs/src/auth.rs +++ b/rust/loki-rs/src/auth.rs @@ -225,7 +225,8 @@ pub fn check_bearer(headers: &HeaderMap, key: &str) -> bool { constant_time_eq(got.trim().as_bytes(), key.as_bytes()) } -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { +/// Comparaison en temps constant (secrets). +pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } diff --git a/rust/loki-rs/src/chat/compact.rs b/rust/loki-rs/src/chat/compact.rs index 886e3c4..6c354fd 100644 --- a/rust/loki-rs/src/chat/compact.rs +++ b/rust/loki-rs/src/chat/compact.rs @@ -98,8 +98,8 @@ const THINK_CLOSE: &str = ""; // Rappels de loki (llm_injected.go) : reconnus pour ne pas les prendre pour // une demande de l'utilisateur. const LOKI_NOTE_PREFIX: &str = "[system] "; -const THINK_NUDGE_FIRST: &str = "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."; -const THINK_NUDGE_STUCK: &str = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."; +pub(crate) const THINK_NUDGE_FIRST: &str = "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."; +pub(crate) const THINK_NUDGE_STUCK: &str = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."; const RETRY_CORRECTIVE_LEAD: &str = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n"; const TOOLS_OFF_HINT: &str = "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered."; @@ -110,8 +110,8 @@ const BUDGET_NUDGE_ENDS: [&str; 3] = [ ]; // Blocs (chat_projsnap.go). -const CTX_UPD_OPEN: &str = " bool { +pub(crate) fn is_loki_injected(m: &Message) -> bool { if m.role != "user" { return false; } @@ -462,7 +462,7 @@ fn strip_ctx_upd_text(s: &str) -> (String, bool) { /// `withoutCtxUpdate` : le message sans son bloc de tête /// (seulement un message `user` marqué `ctx_upd`). -fn without_ctx_update(m: &Message) -> Option { +pub(crate) fn without_ctx_update(m: &Message) -> Option { if m.role != "user" || !m.ctx_upd { return None; } @@ -504,7 +504,7 @@ fn without_ctx_update(m: &Message) -> Option { } /// `stripContextUpdates` : l'historique sans aucun bloc . -fn strip_context_updates(msgs: Vec) -> Vec { +pub(crate) fn strip_context_updates(msgs: Vec) -> Vec { msgs.into_iter() .map(|m| without_ctx_update(&m).unwrap_or(m)) .collect() @@ -644,7 +644,12 @@ pub struct RecallEntry { pub trait RecallArchive { /// Range `content` VERBATIM et rend son id (« r7 »). Une erreur (base /// absente…) laisse le bloc non archivé : repli transparent. - fn archive(&self, label: &str, role: &str, content: &str) -> Result; + fn archive( + &self, + label: &str, + role: &str, + content: &str, + ) -> impl Future> + Send; } /// Archive absente : rien n'est jamais archivé. @@ -652,7 +657,7 @@ pub trait RecallArchive { pub struct NoRecall; impl RecallArchive for NoRecall { - fn archive(&self, _label: &str, _role: &str, _content: &str) -> Result { + async fn archive(&self, _label: &str, _role: &str, _content: &str) -> Result { Err("archive recall indisponible".into()) } } @@ -1587,7 +1592,7 @@ impl Compactor<'_, S, R> { let content = msg_text(m); let tool = names.get(&m.tool_call_id).map_or("", String::as_str); let label = recall_label(&m.role, tool, &content); - let Ok(id) = self.recall.archive(&label, &m.role, &content) else { + let Ok(id) = self.recall.archive(&label, &m.role, &content).await else { continue; }; let e = RecallEntry { id, label }; @@ -2011,7 +2016,7 @@ mod tests { } impl RecallArchive for MemRecall { - fn archive(&self, label: &str, role: &str, content: &str) -> Result { + async fn archive(&self, label: &str, role: &str, content: &str) -> Result { let mut b = self.blocks.lock().unwrap(); b.push((label.into(), role.into(), content.into())); Ok(format!("r{}", b.len())) diff --git a/rust/loki-rs/src/chat/conv.rs b/rust/loki-rs/src/chat/conv.rs new file mode 100644 index 0000000..ba0e166 --- /dev/null +++ b/rust/loki-rs/src/chat/conv.rs @@ -0,0 +1,499 @@ +//! La discussion en mémoire — port de l'état de `Conversation` +//! (`chat_conversation.go`) et de l'abonnement `subscribeSink`. +//! +//! Une seule discussion ouverte, partagée par tous les appareils. Elle porte : +//! - l'état persisté ([`ConvState`] : vue du modèle, journal d'affichage, +//! Seq, contexte mesuré, bloc projet) et l'identifiant de la discussion +//! chargée ; +//! - l'epoch, incrémenté à chaque reset ou bascule : il invalide les abonnés +//! (qui reçoivent `{reset}` et rejouent) et les deltas d'un tour périmé ; +//! - le verrou du tour (`Generating`) et de quoi l'arrêter. +//! +//! Les abonnés ([`Subscription`]) lisent le journal : rejeu coalescé, puis +//! direct. Ils sont réveillés par un compteur de version (`watch`), marqué lu +//! AVANT de relire l'état : aucun réveil n'est perdu, contrairement à une +//! simple notification qui peut partir entre la lecture et l'attente. +//! +//! Hors de ce module : la boucle d'un tour, la file d'attente des messages +//! envoyés pendant un tour, les discussions multiples (index, bascules) et la +//! base — l'appelant charge et enregistre (voir [`super::persist`]). + +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Map, Value}; +use tokio::sync::watch; + +use super::journal::{self, Delta, live_batch, live_reset_plan, replay_plan}; +use super::persist::{ConvSnap, ConvState, PersistSink, Persister, decode_state, take_snapshot}; + +/// Arrêt du tour en cours (`c.cancel`). +pub type CancelTurn = Box; + +/// Un tour est déjà en cours (`ErrBusy`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Busy; + +impl std::fmt::Display for Busy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("génération en cours") + } +} + +#[derive(Default)] +struct Inner { + st: ConvState, + /// Discussion chargée (`ckActive`) : celle qu'annoncent `{reset}` et + /// `{caught_up}`, et sous laquelle s'enregistrent les instantanés. + id: String, + generating: bool, + epoch: i64, + cancel: Option, +} + +/// La discussion ouverte. À partager dans un [`Arc`] (abonnés, tour). +pub struct Conversation { + inner: Mutex, + version: watch::Sender, +} + +impl Default for Conversation { + fn default() -> Self { + Conversation { + inner: Mutex::new(Inner::default()), + version: watch::Sender::new(0), + } + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +impl Conversation { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + fn lock(&self) -> MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Réveille les abonnés (`cond.Broadcast`). + fn bump(&self) { + self.version.send_modify(|v| *v = v.wrapping_add(1)); + } + + /// Identifiant de la discussion chargée. + pub fn id(&self) -> String { + self.lock().id.clone() + } + + pub fn epoch(&self) -> i64 { + self.lock().epoch + } + + /// `isGenerating`. + pub fn is_generating(&self) -> bool { + self.lock().generating + } + + /// `loadFrom` : remplace l'état par la discussion `id` (`body` vide = fil + /// neuf), arrête le tour en cours et invalide les abonnés, qui vident + /// l'écran et rejouent le nouveau fil. + pub fn load_from(&self, id: &str, body: &[u8]) { + self.stop(); + let st = if body.is_empty() { + ConvState::default() + } else { + decode_state(body) + }; + { + let mut g = self.lock(); + g.st = st; + g.id = id.to_string(); + g.generating = false; + g.cancel = None; + g.epoch += 1; + } + self.bump(); + } + + /// `Reset` : fil vide pour tous les appareils, tour arrêté et verrou + /// rendu — même un tour coincé (moteur redémarré sous ses pieds) : c'est + /// le geste qui débloque. L'enregistrement et les fichiers de la + /// discussion restent à l'appelant. + pub fn reset(&self) { + self.stop(); + { + let mut g = self.lock(); + g.st = ConvState::default(); + g.generating = false; + g.cancel = None; + g.epoch += 1; + } + self.bump(); + } + + /// `Stop` : arrête le tour en cours (le verrou est rendu par sa fin). + pub fn stop(&self) { + let cancel = self.lock().cancel.take(); + if let Some(c) = cancel { + c(); + } + } + + /// Prend le verrou du tour ; rend l'epoch du tour, que ses deltas portent. + pub fn begin_turn(&self, cancel: CancelTurn) -> Result { + let mut g = self.lock(); + if g.generating { + return Err(Busy); + } + g.generating = true; + g.cancel = Some(cancel); + Ok(g.epoch) + } + + /// Rend le verrou d'un tour qui n'a pas pu démarrer (moteur tenu par le + /// Go), sans rien coalescer ni réveiller. + pub fn release_turn(&self, epoch: i64) { + let mut g = self.lock(); + if g.epoch == epoch { + g.generating = false; + g.cancel = None; + } + } + + /// Fin d'un tour : le journal du tour est coalescé (`compactLogLocked`) + /// et le verrou rendu. Un tour dont l'epoch est périmé (reset, bascule) + /// ne touche à rien : le verrou a déjà été rendu, peut-être repris. + pub fn end_turn(&self, epoch: i64) -> bool { + { + let mut g = self.lock(); + if g.epoch != epoch { + return false; + } + let log = std::mem::take(&mut g.st.log); + g.st.log = journal::compact_log_owned(log); + g.generating = false; + g.cancel = None; + } + self.bump(); + true + } + + /// `appendDelta` : journalise un événement d'affichage et réveille les + /// abonnés. Un delta d'un epoch périmé appartient à l'ancien fil : jeté. + pub fn append_delta(&self, epoch: i64, delta: Delta) -> bool { + { + let mut g = self.lock(); + if g.epoch != epoch { + return false; + } + let Inner { st, .. } = &mut *g; + journal::append_delta(&mut st.log, &mut st.seq, now_ms(), delta); + } + self.bump(); + true + } + + /// Accès à l'état (vue du modèle, contexte…) sous le verrou, pour le tour. + /// Ne réveille personne : le journal se modifie par [`Self::append_delta`]. + pub fn with_state(&self, f: impl FnOnce(&mut ConvState) -> R) -> R { + f(&mut self.lock().st) + } + + /// [`Self::with_state`], seulement si l'epoch n'a pas changé (reset ou + /// bascule pendant le tour : rien de ce tour ne doit toucher au nouveau fil). + pub fn with_state_if(&self, epoch: i64, f: impl FnOnce(&mut ConvState) -> R) -> Option { + let mut g = self.lock(); + (g.epoch == epoch).then(|| f(&mut g.st)) + } + + /// Instantané à enregistrer, numéroté sous le même verrou que l'état. + pub fn snapshot(&self, p: &Persister, project: &str) -> ConvSnap { + let g = self.lock(); + take_snapshot(&g.st, g.generating, &g.id, p.next_seq(), project) + } + + /// `state` : instantané léger pour `/api/chat/state`. La tâche Go qui + /// tiendrait le moteur est ajoutée par l'appelant. + pub fn state_json(&self) -> Map { + let g = self.lock(); + let turns = + g.st.log + .iter() + .filter(|ev| ev.delta.contains_key("user")) + .count(); + let mut m = Map::new(); + m.insert("seq".into(), g.st.seq.into()); + m.insert("generating".into(), g.generating.into()); + m.insert("ctx_used".into(), g.st.ctx_used.into()); + m.insert("turns".into(), turns.into()); + m + } + + /// Abonnement au journal. `from` : dernier Seq vu par le client ; `tail` : + /// échanges à rejouer pour un client paginé (`< 0` : tout) ; `conv_id` : + /// discussion affichée par le client (vide : inconnue). + pub fn subscribe(self: &Arc, from: i64, tail: i64, conv_id: &str) -> Subscription { + Subscription { + conv: self.clone(), + rx: self.version.subscribe(), + from, + tail, + conv_id: conv_id.to_string(), + epoch: 0, + last: 0, + started: false, + } + } +} + +/// Un abonné au journal (`subscribeSink`). Chaque [`Self::next`] rend le lot +/// suivant à émettre, dans l'ordre : d'abord le rejeu (padding, reset éventuel, +/// pagination, rejeu coalescé, `caught_up`, contexte, padding), puis les +/// événements du direct dès qu'il y en a. Lâcher l'abonnement l'arrête. +pub struct Subscription { + conv: Arc, + rx: watch::Receiver, + from: i64, + tail: i64, + conv_id: String, + epoch: i64, + last: i64, + started: bool, +} + +impl Subscription { + /// Lot suivant ; attend s'il n'y a rien de neuf. Jamais vide. + pub async fn next(&mut self) -> Vec { + loop { + // Marqué lu AVANT de relire l'état : un ajout qui suit réveille. + self.rx.borrow_and_update(); + if let Some(events) = self.poll() { + return events; + } + if self.rx.changed().await.is_err() { + // Discussion détruite : plus rien ne viendra. + std::future::pending::<()>().await; + } + } + } + + fn poll(&mut self) -> Option> { + let g = self.conv.lock(); + if !self.started { + self.started = true; + // Le journal est copié sous le verrou ; le rejeu se bâtit hors. + let snapshot = g.st.log.clone(); + let (id, ctx_used) = (g.id.clone(), g.st.ctx_used); + self.epoch = g.epoch; + drop(g); + let plan = replay_plan( + &snapshot, + self.from, + self.tail, + &self.conv_id, + &id, + ctx_used, + ); + self.last = plan.last; + return Some(plan.events); + } + if g.epoch != self.epoch { + self.epoch = g.epoch; + let plan = live_reset_plan(&g.st.log, self.tail, &g.id, g.st.ctx_used); + self.last = plan.last; + return Some(plan.events); + } + let plan = live_batch(&g.st.log, self.last); + if plan.events.is_empty() { + return None; + } + self.last = plan.last; + Some(plan.events) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::persist::{CommitOutcome, ResGate, ToolResJob}; + use serde_json::json; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + fn d(v: Value) -> Delta { + let Value::Object(m) = v else { + panic!("objet attendu") + }; + m + } + + async fn next(sub: &mut Subscription) -> Vec { + tokio::time::timeout(Duration::from_secs(5), sub.next()) + .await + .expect("aucun lot") + } + + fn seqs(evs: &[Delta]) -> Vec { + evs.iter() + .filter_map(|e| e.get("seq").and_then(Value::as_i64)) + .collect() + } + + #[tokio::test] + async fn rejeu_puis_direct() { + let c = Conversation::new(); + c.append_delta(0, d(json!({"user": "salut"}))); + c.append_delta(0, d(json!({"content": "bon"}))); + let mut sub = c.subscribe(0, -1, ""); + let first = next(&mut sub).await; + assert_eq!(seqs(&first), vec![1, 2]); + assert!( + first + .iter() + .any(|e| e.get("caught_up") == Some(&json!(true))) + ); + c.append_delta(0, d(json!({"content": "jour"}))); + assert_eq!(seqs(&next(&mut sub).await), vec![3]); + } + + #[tokio::test] + async fn abonne_depuis_un_seq() { + let c = Conversation::new(); + c.append_delta(0, d(json!({"user": "a"}))); + c.append_delta(0, d(json!({"user": "b"}))); + let mut sub = c.subscribe(1, -1, ""); + assert_eq!(seqs(&next(&mut sub).await), vec![2]); + } + + #[tokio::test] + async fn curseur_d_une_autre_discussion_rejoue_tout() { + let c = Conversation::new(); + c.load_from("c1", b""); + let e = c.epoch(); + c.append_delta(e, d(json!({"user": "premier message"}))); + c.append_delta(e, d(json!({"content": "réponse"}))); + let mut sub = c.subscribe(9999, -1, "c0"); + let evs = next(&mut sub).await; + assert_eq!(evs[1], d(json!({"reset": true, "id": "c1"}))); + assert!( + evs.iter() + .any(|e| e.get("user") == Some(&json!("premier message"))) + ); + } + + #[tokio::test] + async fn reset_prevenu_et_deltas_perimes_jetes() { + let c = Conversation::new(); + let e0 = c.epoch(); + c.append_delta(e0, d(json!({"user": "x"}))); + let mut sub = c.subscribe(0, -1, ""); + next(&mut sub).await; + c.reset(); + let evs = next(&mut sub).await; + assert_eq!(evs[0].get("reset"), Some(&json!(true))); + assert!(!c.append_delta(e0, d(json!({"content": "fantôme"})))); + assert_eq!(c.state_json()["seq"], 0); + assert!(c.append_delta(c.epoch(), d(json!({"user": "nouveau"})))); + assert_eq!(seqs(&next(&mut sub).await), vec![1]); + } + + #[tokio::test] + async fn bascule_charge_le_fil_et_previent() { + let c = Conversation::new(); + c.load_from("a", b""); + c.append_delta(c.epoch(), d(json!({"user": "dans A"}))); + let mut sub = c.subscribe(0, 20, "a"); + next(&mut sub).await; + let body = ConvState { + log: vec![crate::chat::types::LogEvent { + seq: 1, + ts: 5, + delta: d(json!({"user": "dans B"})), + }], + seq: 1, + ctx_used: 77, + ..Default::default() + } + .to_json(); + c.load_from("b", &body); + let evs = next(&mut sub).await; + assert_eq!(evs[0], d(json!({"reset": true, "id": "b"}))); + assert_eq!(c.id(), "b"); + assert_eq!(c.state_json()["ctx_used"], 77); + // Fil court : rien de masqué, le direct rejoue tout depuis 0. + assert_eq!(seqs(&next(&mut sub).await), vec![1]); + } + + #[tokio::test] + async fn verrou_du_tour() { + let c = Conversation::new(); + let stopped = Arc::new(AtomicBool::new(false)); + let s = stopped.clone(); + let e = c + .begin_turn(Box::new(move || s.store(true, Ordering::SeqCst))) + .unwrap(); + assert!(c.is_generating()); + assert_eq!(c.begin_turn(Box::new(|| {})), Err(Busy)); + c.append_delta(e, d(json!({"content": "Bon"}))); + c.append_delta(e, d(json!({"content": "jour"}))); + assert!(c.end_turn(e)); + assert!(!c.is_generating()); + // Fin de tour : journal coalescé. + c.with_state(|st| { + assert_eq!(st.log.len(), 1); + assert_eq!(st.log[0].delta["content"], "Bonjour"); + }); + // Reset pendant un tour : arrêté, verrou rendu, fin périmée sans effet. + let s = stopped.clone(); + let e = c + .begin_turn(Box::new(move || s.store(true, Ordering::SeqCst))) + .unwrap(); + c.reset(); + assert!(stopped.load(Ordering::SeqCst)); + assert!(!c.is_generating()); + let e2 = c.begin_turn(Box::new(|| {})).unwrap(); + assert!(!c.end_turn(e), "fin d'un tour périmé acceptée"); + assert!(c.is_generating()); + assert!(c.end_turn(e2)); + } + + #[tokio::test] + async fn etat_et_instantane() { + struct Rien; + impl PersistSink for Rien { + async fn commit( + &self, + _: Vec, + _: Vec, + _: ResGate, + ) -> CommitOutcome { + CommitOutcome::default() + } + } + let p = Persister::new(Rien); + let c = Conversation::new(); + c.load_from("c9", b""); + let e = c.begin_turn(Box::new(|| {})).unwrap(); + c.append_delta(e, d(json!({"user": "q"}))); + c.append_delta(e, d(json!({"content": "a"}))); + c.append_delta(e, d(json!({"content": "b"}))); + c.with_state(|st| st.ctx_used = 12); + assert_eq!( + Value::Object(c.state_json()), + json!({"seq": 3, "generating": true, "ctx_used": 12, "turns": 1}) + ); + let s1 = c.snapshot(&p, "generale"); + let s2 = c.snapshot(&p, "generale"); + assert_eq!((s1.id.as_str(), s1.turns), ("c9", 1)); + assert!(s2.seq > s1.seq); + // En plein tour, l'instantané porte le journal compacté. + assert_eq!(decode_state(&s1.body).log.len(), 2); + } +} diff --git a/rust/loki-rs/src/chat/engine.rs b/rust/loki-rs/src/chat/engine.rs index 1c754f0..fec61b5 100644 --- a/rust/loki-rs/src/chat/engine.rs +++ b/rust/loki-rs/src/chat/engine.rs @@ -193,6 +193,21 @@ impl EngineHttp { } } +/// `healthCheck` : `/health` doit répondre tout de suite ou pas du tout +/// (`healthClient`, 3 s) — un moteur qui accepte la connexion sans répondre +/// (très gros modèle en chargement) ne doit pas bloquer l'envoi. +pub const HEALTH_TIMEOUT: Duration = Duration::from_secs(3); + +impl EngineHttp { + /// Le moteur répond 200 sur `url` (`/health`) dans le délai. + pub async fn health(&self, url: &str) -> bool { + match tokio::time::timeout(HEALTH_TIMEOUT, self.client.get(url).send()).await { + Ok(Ok(r)) => r.status().as_u16() == 200, + _ => false, + } + } +} + /// Fournisseur cryptographique de rustls (ring), posé une fois. fn install_crypto() { #[cfg(feature = "tls")] diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs new file mode 100644 index 0000000..a81882f --- /dev/null +++ b/rust/loki-rs/src/chat/generate.rs @@ -0,0 +1,1067 @@ +//! Un tour de discussion de bout en bout — port de `EnqueueOrStart`, +//! `StartTurn`, `generate`, `drainQueued`, `startQueuedIfAny`, `dropQueued` +//! et `compactAndPublish` (`chat_conversation.go`). +//! +//! Envoi : doublon d'un réessai reconnu (`cid`), tour préparé par le Go +//! (`/internal/v1/turn/start` : pièces jointes, capacités, rôle du mode Code, +//! puce), mis en file si un tour tourne déjà, sinon lancé : moteur prêt +//! (`/health`), verrou du tour en Rust puis bail du moteur du Go (partagé +//! avec tâches, bench, optimiseur), bulle utilisateur journalisée et +//! enregistrée tout de suite. +//! +//! Le tour (`generate`) : compaction de début de tour, vue d'envoi +//! (`/internal/v1/turn/view` + historique), boucle ([`run_turn`]) dont chaque +//! événement devient un delta du journal, historique rangé (outils, réponse, +//! raisonnement renvoyé, rappels en porte-à-faux retirés), contexte estimé +//! quand le moteur ne le compte pas, compaction de fin de tour, puis fin : +//! `turn_done`, journal coalescé, verrou et bail rendus, discussion +//! enregistrée, et la file — abandonnée après un stop, sinon le message +//! suivant part (ou la notification Web Push s'il n'y en a pas). +//! +//! Mode Code : la boucle du contrat (vérification isolée, relances et +//! corrections du builder) suit le tour, avant la compaction de fin +//! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé. +//! +//! Écarts assumés (restent au Go) : PROJ_SNAPSHOT et KEEP_TURN_IMAGES +//! (refusés par `/turn/view`), préchauffage, télémétrie, motifs +//! `retry_patterns` personnalisés. + +use std::collections::VecDeque; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use tokio::sync::watch; + +use super::compact::{ + CompactCaps, CompactConfig, CompactOpts, Compactor, TurnView, compact_needed, compact_opts_for, + ctx_pending, estimate_tokens, log_compact, log_ctx_estimate, strip_context_updates, +}; +use super::engine::EngineHttp; +use super::history::{ + drop_stray_nudges, history_from_view, inject_prefix, remind_read_mem_pages, tool_used_delta, +}; +use super::host::{ChatShared, EngineSummarizer, GoTurnHost, StoreRecall, TurnCompact}; +use super::journal::Delta; +use super::lease::{EngineLease, LEASE_TTL, LeaseError, LeaseSignal, engine_stop}; +use super::sessions::{GoSessionHooks, Sessions}; +use super::turn::{Cancel, TurnConfig, run_turn}; +use super::types::{Message, ReasoningEcho, StreamEvent, Tool}; +use super::wire::{echo_policy_for, resolve_chat_endpoint, turn_reasoning, with_echo}; +use crate::engstatus::llm_port; +use crate::store::BK_STATE; + +/// `errModelLoading` : une attente, pas un défaut. +pub const ERR_MODEL_LOADING: &str = + "⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes."; +/// `ErrBusy`. +pub const ERR_BUSY: &str = "génération en cours"; +/// Identifiants d'envoi retenus (`recentCIDs`). +pub const RECENT_CIDS: usize = 64; + +/// Un tour préparé par le Go (`/internal/v1/turn/start`). +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Prepared { + #[serde(default)] + pub files: Value, + /// Capacités demandées. + #[serde(default)] + pub caps: Value, + /// Capacités du tour (rôle du mode Code figé). + #[serde(default)] + pub turn_caps: Value, + /// Le message utilisateur vu par le modèle. + #[serde(default)] + pub content: Value, + #[serde(default)] + pub model: String, + #[serde(default)] + pub code_hint: bool, + /// Texte tapé (bulle du journal). + #[serde(skip)] + pub text: String, + #[serde(skip)] + pub temperature: f64, +} + +impl Prepared { + fn cap(&self, k: &str) -> bool { + self.turn_caps + .get(k) + .and_then(Value::as_bool) + .unwrap_or(false) + } + + /// La bulle utilisateur du journal : texte, modèle qui répond, fichiers. + fn user_delta(&self) -> Delta { + let mut d = Map::new(); + d.insert("user".into(), self.text.clone().into()); + if !self.model.is_empty() { + d.insert("model".into(), self.model.clone().into()); + } + if self.files.as_array().is_some_and(|f| !f.is_empty()) { + d.insert("files".into(), self.files.clone()); + } + d + } + + fn user_message(&self) -> Message { + Message { + role: "user".into(), + content: Some(self.content.clone()), + ..Default::default() + } + } +} + +/// Envoi refusé : statut HTTP de `/api/chat/send` et message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refused { + pub status: u16, + pub error: String, +} + +/// Envoi accepté. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Sent { + /// Mis en file derrière le tour en cours. + pub queued: bool, + /// Réessai d'un envoi déjà accepté. + pub dup: bool, +} + +enum StartError { + /// Le tour de la discussion tient déjà le verrou. + ConvBusy, + /// Le moteur est tenu par le Go (tâche, bench, optimiseur) : 409. + Busy(String), + /// Moteur pas prêt : 503. + NotReady(String), +} + +#[derive(Default)] +struct Queue { + items: VecDeque, + recent: VecDeque, +} + +pub(super) fn obj(k: &str, v: impl Into) -> Delta { + let mut d = Map::new(); + d.insert(k.into(), v.into()); + d +} + +type BoxFut = Pin + Send>>; + +/// Ce qu'un tour lit une fois au départ. +pub(super) struct TurnEnv { + pub(super) cfg: std::collections::HashMap, + pub(super) ep: super::wire::ChatEndpoint, + pub(super) echo: super::wire::EchoPolicy, + pub(super) ccfg: CompactConfig, + pub(super) port: u16, + pub(super) summarizer: EngineSummarizer, +} + +/// Le chat tenu par la façade : la discussion ouverte, ses tours et sa file. +pub struct ChatService { + pub sessions: Arc>, + pub shared: Arc, + q: Mutex, + lease_ttl: Duration, +} + +impl ChatService { + pub fn new(sessions: Arc>, shared: Arc) -> Arc { + Arc::new(ChatService { + sessions, + shared, + q: Mutex::new(Queue::default()), + lease_ttl: LEASE_TTL, + }) + } + + fn queue(&self) -> std::sync::MutexGuard<'_, Queue> { + self.q.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// `seenCIDLocked` : retient `cid` et dit s'il avait déjà été vu. + fn seen_cid(&self, cid: &str) -> bool { + if cid.is_empty() { + return false; + } + let mut q = self.queue(); + if q.recent.iter().any(|c| c == cid) { + return true; + } + q.recent.push_back(cid.to_string()); + while q.recent.len() > RECENT_CIDS { + q.recent.pop_front(); + } + false + } + + /// Un envoi refusé peut être renvoyé : son cid ne reste pas marqué. + fn forget_cid(&self, cid: &str) { + self.queue().recent.retain(|c| c != cid); + } + + /// `/api/chat/send` (`EnqueueOrStart`) : corps tel que l'UI l'envoie. + pub async fn send(self: &Arc, body: Value) -> Result { + let cid = body + .get("cid") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + if self.seen_cid(&cid) { + return Ok(Sent { + queued: false, + dup: true, + }); + } + let refuse = |me: &Self, status: u16, error: String| { + me.forget_cid(&cid); + Err(Refused { status, error }) + }; + let prep = match self + .shared + .store + .internal_post("/internal/v1/turn/start", &body) + .await + { + Ok((s, v)) if s.as_u16() == 200 => { + serde_json::from_value::(v).unwrap_or_default() + } + Ok((s, v)) => { + let e = v + .get("error") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + return refuse(self, if s.as_u16() == 400 { 400 } else { 503 }, e); + } + Err(e) => return refuse(self, 503, e), + }; + let prep = Prepared { + text: body + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + temperature: body + .get("temperature") + .and_then(Value::as_f64) + .unwrap_or(0.0), + ..prep + }; + let conv = self.sessions.conv(); + if conv.is_generating() { + self.queue().items.push_back(prep); + return Ok(Sent { + queued: true, + dup: false, + }); + } + match self.start(prep.clone()).await { + Ok(()) => Ok(Sent::default()), + // Un autre tour a démarré entre-temps (deux appareils) : en file. + Err(StartError::ConvBusy) => { + self.queue().items.push_back(prep); + self.start_queued_if_any().await; + Ok(Sent { + queued: true, + dup: false, + }) + } + Err(StartError::Busy(e)) => refuse(self, 409, e), + Err(StartError::NotReady(e)) => refuse(self, 503, e), + } + } + + /// `healthCheck` : preset externe, toujours prêt ; sinon `/health`. + async fn engine_ready(&self) -> bool { + let cfg = self.shared.store.config().await; + if crate::engstatus::is_external(&cfg) { + return true; + } + let url = format!("http://localhost:{}/health", llm_port(&cfg)); + self.shared.http.health(&url).await + } + + /// `StartTurn`. Futur en boîte : la fin d'un tour relance la file, donc + /// `start` → `generate` → `start`, un cycle que le type doit couper. + fn start( + self: &Arc, + prep: Prepared, + ) -> Pin> + Send + '_>> { + Box::pin(self.start_inner(prep)) + } + + async fn start_inner(self: &Arc, prep: Prepared) -> Result<(), StartError> { + if !self.engine_ready().await { + return Err(StartError::NotReady(ERR_MODEL_LOADING.into())); + } + let conv = self.sessions.conv().clone(); + let (tx, cancel) = Cancel::new(); + let tx = Arc::new(tx); + let t = tx.clone(); + let epoch = conv + .begin_turn(Box::new(move || { + let _ = t.send(true); + })) + .map_err(|_| StartError::ConvBusy)?; + let lease = match EngineLease::acquire(self.shared.store.clone(), self.lease_ttl).await { + Ok(l) => l, + Err(e) => { + conv.release_turn(epoch); + return Err(match e { + LeaseError::Busy { error, .. } => StartError::Busy(error), + LeaseError::Other(e) => StartError::NotReady(e), + }); + } + }; + conv.with_state_if(epoch, |st| st.messages.push(prep.user_message())); + conv.append_delta(epoch, prep.user_delta()); + if prep.code_hint && !prep.cap("Code") { + conv.append_delta(epoch, obj("code_hint", true)); + } + // Instantané tout de suite, écriture par l'écrivain : un crash en + // pleine génération ne perd pas le message de l'utilisateur. + self.sessions.persist_async().await; + let me = self.clone(); + let fut: BoxFut = + Box::pin(async move { me.generate(prep, epoch, cancel, tx, lease).await }); + tokio::spawn(fut); + Ok(()) + } + + /// `drainQueued` : messages en file injectés dans le tour en cours, + /// journalisés pour tous les appareils. + fn drain_queued(&self, epoch: i64) -> Vec { + let conv = self.sessions.conv(); + if conv.epoch() != epoch { + return Vec::new(); + } + let items: Vec = self.queue().items.drain(..).collect(); + items + .into_iter() + .map(|p| { + conv.append_delta(epoch, p.user_delta()); + p.user_message() + }) + .collect() + } + + /// `startQueuedIfAny` : le prochain message de la file part, s'il en reste + /// et que rien ne tourne. Rend vrai si un tour a démarré. + async fn start_queued_if_any(self: &Arc) -> bool { + loop { + let conv = self.sessions.conv(); + if conv.is_generating() { + return false; + } + let Some(next) = self.queue().items.pop_front() else { + return false; + }; + let epoch = conv.epoch(); + match self.start(next.clone()).await { + Ok(()) => return true, + // Un tour vient de partir : le message garde sa place, ce tour + // le reprendra (injection ou fin de tour). + Err(StartError::ConvBusy) => { + self.queue().items.push_front(next); + return false; + } + Err(StartError::Busy(e) | StartError::NotReady(e)) => { + conv.append_delta( + epoch, + obj("error", format!("message en attente non envoyé : {e}")), + ); + } + } + } + } + + /// `dropQueued` : bouton stop, l'utilisateur reprend la main. + fn drop_queued(&self, epoch: i64) { + let n = std::mem::take(&mut self.queue().items).len(); + if n > 0 { + self.sessions + .conv() + .append_delta(epoch, obj("queue_dropped", n)); + } + } + + /// `/api/chat/stop` : le tour en cours, ou le détenteur Go du moteur + /// (tâche planifiée, bench, optimiseur). + pub async fn stop(&self) { + let conv = self.sessions.conv(); + if conv.is_generating() { + conv.stop(); + } else if let Err(e) = engine_stop(&self.shared.store).await { + eprintln!("[chat] arrêt du détenteur du moteur impossible : {e}"); + } + } + + /// `compactAndPublish` : bannière, journal, installation du résultat, + /// jauge de contexte. Rend l'historique compacté, `None` s'il n'a pas + /// changé. + #[allow(clippy::too_many_arguments)] + async fn compact_and_publish( + &self, + summarizer: &EngineSummarizer, + ccfg: &CompactConfig, + caps: CompactCaps, + mem_off: bool, + epoch: i64, + phase: &str, + msgs: &[Message], + ctx_used: i64, + opt: &CompactOpts, + ) -> Option> { + let conv = self.sessions.conv(); + conv.append_delta(epoch, obj("compacting", true)); + let recall = StoreRecall(self.shared.store.clone()); + let criteria = if caps.code { + super::host::live_criteria(&self.shared.store, &conv.id()).await + } else { + String::new() + }; + let c = Compactor { + cfg: ccfg, + summarizer, + recall: &recall, + memory: &self.shared.compact_memory, + criteria: &criteria, + }; + let (compacted, changed) = c.compact_messages_noted(msgs, caps, opt).await; + conv.append_delta(epoch, obj("compacting", false)); + log_compact(ccfg, phase, ctx_used, msgs, &compacted, changed); + if !changed { + conv.append_delta(epoch, obj("compact_noop", true)); + return None; + } + let compacted = if caps.agent { + remind_read_mem_pages(compacted, msgs, mem_off) + } else { + compacted + }; + let compacted = strip_context_updates(compacted); + let overhead = (ctx_used - estimate_tokens(msgs, &ccfg.echo)).max(0); + let est = estimate_tokens(&compacted, &ccfg.echo) + overhead; + conv.with_state_if(epoch, |st| { + st.messages = compacted.clone(); + st.proj_snap = None; + st.ctx_used = est; + st.ctx_used_len = compacted.len(); + }); + conv.append_delta(epoch, obj("compacted", true)); + conv.append_delta(epoch, obj("ctx_used", est)); + Some(compacted) + } + + /// `/internal/v1/turn/view` : tête (prompt du preset, consignes, + /// projet), préambule et outils du tour ; `Err` : le message à journaliser. + pub(super) async fn turn_view( + &self, + caps: &Value, + ) -> Result<(Vec, String, Vec), String> { + match self + .shared + .store + .internal_post("/internal/v1/turn/view", &json!({ "caps": caps })) + .await + { + Ok((s, v)) if s.as_u16() == 200 => Ok(( + serde_json::from_value::>(v["head"].clone()).unwrap_or_default(), + v["prefix"].as_str().unwrap_or("").to_string(), + serde_json::from_value::>(v["tools"].clone()).unwrap_or_default(), + )), + Ok((_, v)) => Err(v["error"] + .as_str() + .unwrap_or("vue du tour refusée") + .to_string()), + Err(e) => Err(e), + } + } + + /// Configuration, point de chat et résumeur d'un tour (lus une fois). + pub(super) async fn turn_env(&self) -> TurnEnv { + let store = &self.shared.store; + 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 ccfg = CompactConfig::from_config(&cfg, echo.clone()); + let port = llm_port(&cfg); + let summarizer = EngineSummarizer { + http: self.shared.http.clone(), + ep: ep.clone(), + cfg: cfg.clone(), + patterns: self.shared.patterns.clone(), + port, + }; + TurnEnv { + cfg, + ep, + echo, + ccfg, + port, + summarizer, + } + } + + /// `/api/chat/reset` : fil vide pour tous les appareils, tour arrêté et + /// verrou rendu, file vidée, fichiers de la discussion effacés. + pub async fn reset(&self) { + let conv = self.sessions.conv(); + conv.reset(); + self.queue().items.clear(); + let body = json!({ "id": conv.id(), "files_only": true }); + if let Err(e) = self + .shared + .store + .internal_json( + hyper::Method::POST, + "/internal/v1/conv/dropped", + Some(&body), + ) + .await + { + eprintln!("[chat] fichiers de la discussion non effacés : {e}"); + } + self.sessions.persist().await; + } + + /// `/api/chat/compact` (`CompactNow`) : compaction manuelle, en tâche de + /// fond ; la progression passe par le journal. + pub async fn compact_now(self: &Arc) -> Result<(), Refused> { + if !self.engine_ready().await { + return Err(Refused { + status: 503, + error: ERR_MODEL_LOADING.into(), + }); + } + let conv = self.sessions.conv().clone(); + let epoch = conv.begin_turn(Box::new(|| {})).map_err(|_| Refused { + status: 409, + error: ERR_BUSY.into(), + })?; + let lease = match EngineLease::acquire(self.shared.store.clone(), self.lease_ttl).await { + Ok(l) => l, + Err(e) => { + conv.release_turn(epoch); + return Err(match e { + LeaseError::Busy { error, .. } => Refused { status: 409, error }, + LeaseError::Other(error) => Refused { status: 503, error }, + }); + } + }; + let me = self.clone(); + tokio::spawn(async move { + let env = me.turn_env().await; + let local = !env.ep.external; + let snap = conv.with_state_if(epoch, |st| { + let msgs = st.messages.clone(); + let used = if local { + ctx_pending(st.ctx_used, st.ctx_used_len, &msgs, &env.echo) + } else { + st.ctx_used + }; + (msgs, used) + }); + if let Some((msgs, used)) = snap { + let changed = me + .compact_and_publish( + &env.summarizer, + &env.ccfg, + CompactCaps::default(), + true, + epoch, + "manuel", + &msgs, + used, + &CompactOpts::default(), + ) + .await + .is_some(); + if changed { + me.sessions.persist().await; + } + } + conv.release_turn(epoch); + lease.release().await; + }); + Ok(()) + } + + /// `/api/chat/tool-result` (`loadToolResult`) : le résultat complet d'un + /// outil — en mémoire tant qu'il n'est pas écrit, sinon en base. + pub async fn tool_result(&self, id: &str) -> Option { + if id.contains(['/', '\\']) { + return None; + } + if let Some(s) = self.shared.persister.tool_res_pending(id) { + return Some(s.to_string()); + } + let b = self + .shared + .store + .get_open(super::persist::BK_TOOL_RES, id) + .await + .ok() + .flatten()?; + Some(String::from_utf8_lossy(&b).into_owned()) + } + + /// Changement de projet (`/api/projects`, action switch), remis par le + /// Go : refusé pendant un tour. + pub async fn project_switch(&self, slug: &str) -> Result<(), Refused> { + if self.sessions.conv().is_generating() { + return Err(Refused { + status: 409, + error: "une génération est en cours".into(), + }); + } + self.sessions + .project_switch(slug) + .await + .map_err(|e| Refused { + status: 400, + error: e.to_string(), + }) + } + + /// Suppression d'un projet (`deleteProject`) : ses discussions d'abord, + /// refusé pendant un tour. + pub async fn drop_project(&self, slug: &str) -> Result { + if self.sessions.conv().is_generating() { + return Err(Refused { + status: 409, + error: "une génération est en cours".into(), + }); + } + self.sessions.drop_project(slug).await.map_err(|e| Refused { + status: 400, + error: e.to_string(), + }) + } + + /// Un événement d'affichage né hors d'un tour (bascule du mode Code, + /// critères édités), sur le fil courant. + pub fn push_delta(&self, d: Delta) { + let conv = self.sessions.conv(); + conv.append_delta(conv.epoch(), d); + } + + /// `generate` : un tour complet, détaché — seul l'arrêt l'interrompt. + async fn generate( + self: Arc, + prep: Prepared, + epoch: i64, + cancel: Cancel, + cancel_tx: Arc>, + mut lease: EngineLease, + ) { + let turn_start = Instant::now(); + let conv = self.sessions.conv().clone(); + let env = self.turn_env().await; + let TurnEnv { + cfg, + ep, + echo, + ccfg, + port, + summarizer, + } = &env; + let (cfg, ep, echo, ccfg, port, summarizer) = ( + cfg.clone(), + ep.clone(), + echo.clone(), + ccfg.clone(), + *port, + summarizer.clone(), + ); + let local = !ep.external; + let conv_id = conv.id(); + let agent = prep.cap("Agent"); + let mem_off = prep.turn_caps.get("Mem").and_then(Value::as_str) == Some("off"); + let ccaps = CompactCaps { + agent, + code: prep.cap("Code"), + }; + let ctx_now = |st: &super::persist::ConvState, msgs: &[Message]| { + if local { + ctx_pending(st.ctx_used, st.ctx_used_len, msgs, &echo) + } else { + st.ctx_used + } + }; + + // Instantané de la vue modèle et du contexte. + let snap = conv.with_state_if(epoch, |st| { + let msgs = st.messages.clone(); + let used = ctx_now(st, &msgs); + let peak = std::mem::take(&mut st.gen_peak); + (msgs, used, peak) + }); + let Some((mut msgs, ctx_used, peak)) = snap else { + lease.release().await; + return; + }; + + // Compaction de début de tour. + let mut start_est = ctx_used; + if compact_needed(&ccfg, &msgs, ctx_used, peak) + && !self + .shared + .compact_memory + .refused_skip(&ccfg, &conv_id, ctx_used, &msgs) + { + let opt = compact_opts_for( + &ccfg, + &self.shared.compact_memory, + false, + &msgs, + ctx_used, + &conv_id, + |_| TurnView::default(), + ); + if let Some(out) = self + .compact_and_publish( + &summarizer, + &ccfg, + ccaps, + mem_off, + epoch, + "début-tour", + &msgs, + ctx_used, + &opt, + ) + .await + { + msgs = out; + start_est = conv + .with_state_if(epoch, |st| st.ctx_used) + .unwrap_or(start_est); + } + } + + // Vue d'envoi : tête du Go (prompt du preset, consignes, projet), + // l'historique sans bloc , le préambule. + let (head, prefix, tools) = match self.turn_view(&prep.turn_caps).await { + Ok(v) => v, + Err(e) => { + conv.append_delta(epoch, obj("error", e)); + self.finish(epoch, &cancel, lease, turn_start).await; + return; + } + }; + if agent { + // Une discussion qui porte encore des blocs d'une époque où + // PROJ_SNAPSHOT servait en est nettoyée (comme assembleTurn). + conv.with_state_if(epoch, |st| { + if st.proj_snap.is_some() || st.messages.iter().any(|m| m.ctx_upd) { + st.proj_snap = None; + st.messages = strip_context_updates(std::mem::take(&mut st.messages)); + } + }); + } + let hist = strip_context_updates(msgs); + let mut sent = head; + sent.extend(hist.iter().cloned()); + let sent = inject_prefix(sent, &prefix); + + let temperature = if prep.temperature == 0.0 { + 0.7 + } else { + prep.temperature + }; + let reasoning = turn_reasoning(&cfg, &self.shared.effort); + let tc = TurnConfig { + cfg: cfg.clone(), + ep: ep.clone(), + tools: tools.clone(), + temperature, + code: prep.cap("Code"), + reasoning: reasoning.clone(), + echo: echo.clone(), + compact: ccfg.clone(), + patterns: self.shared.patterns.clone(), + }; + let me = self.clone(); + let host = GoTurnHost { + shared: self.shared.clone(), + conv_id: conv_id.clone(), + caps: prep.turn_caps.clone(), + cfg: cfg.clone(), + port, + summarizer: summarizer.clone(), + compact: TurnCompact { + cfg: ccfg.clone(), + caps: ccaps, + criteria: String::new(), + main_conv: conv_id.clone(), + tools, + effort: reasoning.effort.clone(), + kwargs: reasoning.kwargs.clone().unwrap_or_default(), + }, + queued: Box::new(move || me.drain_queued(epoch)), + }; + + // Chaque événement devient un delta du journal. + let mut content = String::new(); + let mut new_base: Option> = None; + let mut final_echo: Option = None; + let mut saw_usage = false; + let mut turn_peak = 0i64; + let mut asked = false; + let mut cb = |ev: StreamEvent| -> bool { + match ev { + StreamEvent::Err(e) => { + conv.append_delta(epoch, obj("error", e)); + } + StreamEvent::ToolUsed(t) => { + // Le texte écrit avant l'appel est déjà dans le message + // assistant qui porte l'appel : pas deux fois. + content.clear(); + conv.append_delta(epoch, tool_used_delta(&t)); + } + StreamEvent::NewHistory(h) => new_base = Some(history_from_view(&h, &hist)), + StreamEvent::Echo(e) => final_echo = Some(e), + StreamEvent::Compacting(b) => { + conv.append_delta(epoch, obj("compacting", b)); + } + StreamEvent::Stats(s) => { + if s.prompt_tokens_total > 0 { + if !saw_usage && local && start_est > 0 { + log_ctx_estimate("début-tour", start_est, s.prompt_tokens_total); + } + saw_usage = true; + conv.with_state_if(epoch, |st| st.ctx_used = s.ctx_after()); + } + if local { + turn_peak = turn_peak.max(s.gen_tokens); + } + conv.append_delta( + epoch, + obj("stats", serde_json::to_value(&s).unwrap_or_default()), + ); + } + StreamEvent::Ask(a) => { + // Question structurée (outil ask) : carte à boutons. + asked = true; + conv.append_delta( + epoch, + obj("ask", serde_json::to_value(&a).unwrap_or_default()), + ); + } + StreamEvent::DropReasoning => { + conv.append_delta(epoch, obj("drop_reasoning", true)); + } + StreamEvent::Reasoning(r) if !r.is_empty() => { + conv.append_delta(epoch, obj("reasoning_content", r)); + } + StreamEvent::Content(c) if !c.is_empty() => { + content.push_str(&c); + conv.append_delta(epoch, obj("content", c)); + } + _ => {} + } + true // génération détachée : jamais interrompue par un abonné + }; + + let res = { + let turn = run_turn(&host, &tc, sent, &cancel, &mut cb); + tokio::pin!(turn); + tokio::select! { + r = &mut turn => r, + sig = lease.interrupted() => { + if sig == LeaseSignal::Lost { + eprintln!("[chat] bail du moteur perdu en plein tour : arrêt"); + } + let _ = cancel_tx.send(true); + turn.await + } + } + }; + drop(host); + + // Vue modèle rangée : outils puis réponse finale — sauf si un reset + // ou une bascule est passé entre-temps. + let after = conv.with_state_if(epoch, |st| { + if let Some(base) = new_base.take() { + st.messages = base; + } + st.messages.extend(res.extra.iter().cloned()); + if !content.trim().is_empty() { + let a = Message { + role: "assistant".into(), + content: Some(Value::String(content.clone())), + ..Default::default() + }; + st.messages.push(with_echo(a, final_echo.as_ref())); + } + st.messages = drop_stray_nudges(std::mem::take(&mut st.messages)); + if saw_usage { + st.ctx_used_len = st.messages.len(); + } + st.gen_peak = st.gen_peak.max(turn_peak); + let msgs = st.messages.clone(); + (ctx_now(st, &msgs), st.gen_peak, msgs) + }); + let Some((mut ctx_used, mut peak, mut msgs)) = after else { + lease.release().await; + return; // reset pendant le tour : il a déjà enregistré le fil vide + }; + + // Moteur muet sur l'usage : l'estimation, sinon la jauge reste à zéro. + if !saw_usage { + let est = estimate_tokens(&msgs, &echo); + conv.with_state_if(epoch, |st| { + st.ctx_used = est; + st.ctx_used_len = msgs.len(); + }); + conv.append_delta(epoch, obj("ctx_used", est)); + ctx_used = est; + } + + // Boucle du contrat (mode Code) : vérification indépendante des + // critères, corrections, re-vérification — AVANT de rendre la main + // (verify.rs). Rien hors mode Code ou sans critères. + if !cancel.is_cancelled() { + let pe = super::verify::PassEnv { + env: &env, + turn_caps: &prep.turn_caps, + temperature, + reasoning: &reasoning, + ccaps, + conv_id: &conv_id, + }; + let mut run = super::verify::PassRun { + lease: &mut lease, + cancel: &cancel, + cancel_tx: &cancel_tx, + }; + self.code_verify_loop(&pe, epoch, asked, &mut run).await; + let after = conv.with_state_if(epoch, |st| { + let m = st.messages.clone(); + (ctx_now(st, &m), st.gen_peak, m) + }); + let Some(a) = after else { + lease.release().await; + return; // reset pendant la vérification + }; + (ctx_used, peak, msgs) = a; + } + + // Compaction de fin de tour : la jauge retombe tout de suite, le tour + // suivant démarre avec de la marge. Pas après un stop. + if !cancel.is_cancelled() + && compact_needed(&ccfg, &msgs, ctx_used, peak) + && !self + .shared + .compact_memory + .refused_skip(&ccfg, &conv_id, ctx_used, &msgs) + { + let opt = compact_opts_for( + &ccfg, + &self.shared.compact_memory, + false, + &msgs, + ctx_used, + &conv_id, + |_| TurnView::default(), + ); + self.compact_and_publish( + &summarizer, + &ccfg, + ccaps, + mem_off, + epoch, + "fin-tour", + &msgs, + ctx_used, + &opt, + ) + .await; + } + self.finish(epoch, &cancel, lease, turn_start).await; + } + + /// Fin d'un tour (le `defer` de `generate`). + async fn finish( + self: &Arc, + epoch: i64, + cancel: &Cancel, + lease: EngineLease, + turn_start: Instant, + ) { + let conv = self.sessions.conv(); + conv.append_delta(epoch, obj("turn_done", true)); + let current = conv.end_turn(epoch); + lease.release().await; + if !current { + return; // reset pendant le tour : il a déjà enregistré le fil vide + } + self.sessions.persist().await; + if cancel.is_cancelled() { + self.drop_queued(epoch); + return; + } + if !self.start_queued_if_any().await { + let ms = turn_start.elapsed().as_millis() as u64; + if let Err(e) = self + .shared + .store + .internal_json( + hyper::Method::POST, + "/internal/v1/turn/done", + Some(&json!({ "ms": ms })), + ) + .await + { + eprintln!("[chat] fin de tour non signalée : {e}"); + } + } + } +} + +/// Qui tient la conversation, d'après le Go (`/internal/v1/chat/holder`) : +/// `None` : la façade ; `Some(raison)` : le Go la garde (réglage que la façade +/// ne sait pas honorer). Décidé une fois par process, côté Go. +pub async fn go_keeps_chat(store: &crate::store::Store) -> Result, String> { + let v = store + .internal_json(hyper::Method::GET, "/internal/v1/chat/holder", None) + .await?; + match v["holder"].as_str() { + Some("rust") => Ok(None), + Some("go") => Ok(Some(v["reason"].as_str().unwrap_or("").to_string())), + _ => Err(format!("détenteur de la conversation illisible : {v}")), + } +} + +impl ChatShared { + /// L'état partagé des tours, autour de l'écrivain des discussions. + /// `img_dir` : `LOKI_HOME/chatimg`. + pub fn for_sessions( + sessions: &Sessions, + store: crate::store::Store, + img_dir: std::path::PathBuf, + ) -> Self { + ChatShared { + http: EngineHttp::new(), + store, + persister: sessions.persister().clone(), + effort: super::wire::EffortFallbacks::new(), + echo_refusals: super::wire::EchoRefusals::new(), + compact_memory: super::compact::CompactMemory::new(), + images: super::host::ImageCache::new(img_dir), + patterns: super::stream::RetryPatterns::builtin(), + } + } +} diff --git a/rust/loki-rs/src/chat/history.rs b/rust/loki-rs/src/chat/history.rs new file mode 100644 index 0000000..15bdc26 --- /dev/null +++ b/rust/loki-rs/src/chat/history.rs @@ -0,0 +1,536 @@ +//! Début et fin d'un tour, côté historique — port de `historyFromView` +//! (`chat_conversation.go`), `dropStrayNudges` (`llm_injected.go`), du rappel +//! des pages mémoire lues (`chat_mem_pinned.go`), de l'assemblage +//! `InjectSkills` (`llm_client.go`) et de la forme des événements `tool_used` +//! du journal (`generate`). + +use std::collections::HashMap; + +use serde_json::{Map, Value, json}; + +use super::compact::{is_loki_injected, msg_text}; +use super::stream::call_args; +use super::types::{Message, ToolUsedEvent}; +use super::wire::{MEM_REMINDER_PREFIX, is_project_system}; + +/// Noms de pages listés au plus par le rappel (`memReminderMaxNames`). +pub const MEM_REMINDER_MAX_NAMES: usize = 24; + +fn system(text: String) -> Message { + Message { + role: "system".into(), + content: Some(Value::String(text)), + ..Default::default() + } +} + +/// `InjectSkills` avec le préfixe déjà calculé (préambule d'agent + briefing +/// machine, `/internal/v1/turn/view`) : fusionné dans le premier système s'il +/// n'est pas un message projet, sinon posé en tête. Préfixe vide : rien. +pub fn inject_prefix(msgs: Vec, prefix: &str) -> Vec { + if prefix.is_empty() { + return msgs; + } + if let Some(first) = msgs.first() + && first.role == "system" + && !is_project_system(first) + { + let existing = match &first.content { + Some(Value::String(s)) => s.as_str(), + _ => "", + }; + let mut out = msgs.clone(); + out[0] = system(format!("{prefix}\n\n{existing}")); + return out; + } + let mut out = Vec::with_capacity(msgs.len() + 1); + out.push(system(prefix.to_string())); + out.extend(msgs); + out +} + +/// `historyFromView` : l'historique à ranger après une compaction en cours de +/// tour — la vue publiée sans les systèmes que la vue d'envoi avait posés en +/// tête. Un système de tête n'est gardé que s'il figurait en tête de `hist` +/// (le premier, éventuellement fusionné avec le préambule, est rendu tel qu'il +/// était dans l'historique). +pub fn history_from_view(view: &[Message], hist: &[Message]) -> Vec { + let mut own: HashMap = HashMap::new(); + for m in hist.iter().take_while(|m| m.role == "system") { + *own.entry(msg_text(m)).or_default() += 1; + } + let mut out = Vec::new(); + let mut i = 0; + while i < view.len() && view[i].role == "system" { + let k = msg_text(&view[i]); + if let Some(n) = own.get_mut(&k) + && *n > 0 + { + *n -= 1; + out.push(view[i].clone()); + i += 1; + continue; + } + if i == 0 + && let Some(h0) = hist.first() + && h0.role == "system" + { + let h = msg_text(h0); + if !h.is_empty() + && own.get(&h).is_some_and(|n| *n > 0) + && k.ends_with(&format!("\n\n{h}")) + { + if let Some(n) = own.get_mut(&h) { + *n -= 1; + } + out.push(h0.clone()); + } + } + i += 1; + } + out.extend_from_slice(&view[i..]); + out +} + +/// `dropStrayNudges` : retire les rappels de loki en porte-à-faux (en toute +/// fin, ou collés à un autre `user`) — sinon deux `user` d'affilée au tour +/// suivant. +pub fn drop_stray_nudges(mut msgs: Vec) -> Vec { + loop { + let n = msgs.len(); + let drop = (0..n).find(|&i| { + is_loki_injected(&msgs[i]) + && (i == n - 1 + || msgs[i + 1].role == "user" + || (i > 0 && msgs[i - 1].role == "user")) + }); + match drop { + Some(i) => { + msgs.remove(i); + } + None => return msgs, + } + } +} + +/// `parseReminderNames` : noms listés par un rappel, vide si ce n'en est pas. +pub fn parse_reminder_names(text: &str) -> Vec { + if !text.starts_with(MEM_REMINDER_PREFIX) { + return Vec::new(); + } + let line = text.split('\n').next().unwrap_or(""); + let Some(i) = line.rfind(": ") else { + return Vec::new(); + }; + line[i + 2..] + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// `collectReadPageNames` : pages lues (mem_read réussis, puis rappels +/// hérités), dans l'ordre, sans doublon. +pub fn collect_read_page_names(msgs: &[Message]) -> Vec { + let mut read_call: HashMap<&str, String> = HashMap::new(); + for m in msgs { + for tc in &m.tool_calls { + if tc.function.name != "mem_read" || tc.id.is_empty() { + continue; + } + if let Some(Value::String(f)) = call_args(&tc.function.arguments).get("file") + && !f.is_empty() + { + read_call.insert(tc.id.as_str(), f.clone()); + } + } + } + let mut order: Vec = Vec::new(); + let mut add = |name: &str| { + let name = name.trim(); + if !name.is_empty() && !order.iter().any(|n| n == name) { + order.push(name.to_string()); + } + }; + for m in msgs { + match m.role.as_str() { + "system" => { + for n in parse_reminder_names(&msg_text(m)) { + add(&n); + } + } + "tool" => { + let Some(name) = read_call.get(m.tool_call_id.as_str()) else { + continue; + }; + if msg_text(m).trim().starts_with("[erreur]") { + continue; + } + add(name); + } + _ => {} + } + } + order +} + +/// `buildReminderMessage` : le rappel, les plus récentes en dernier ; `None` +/// sans page lue. +pub fn build_reminder_message(names: &[String]) -> Option { + if names.is_empty() { + return None; + } + let names = &names[names.len().saturating_sub(MEM_REMINDER_MAX_NAMES)..]; + Some(system(format!( + "{MEM_REMINDER_PREFIX} Memory pages you read earlier: {}\nTheir full content is no longer inline after context compaction — if any of them is relevant to what you're doing, mem_read it again so you have all the rules and info you need before answering.", + names.join(", ") + ))) +} + +/// `stripReminder`. +pub fn strip_reminder(msgs: Vec) -> Vec { + msgs.into_iter() + .filter(|m| { + !(m.role == "system" + && matches!(&m.content, Some(Value::String(s)) if s.starts_with(MEM_REMINDER_PREFIX))) + }) + .collect() +} + +/// `remindReadMemPages` : rappel reconstruit d'après l'historique d'avant la +/// compaction, en tête de l'historique compacté. Mémoire coupée : rien. +pub fn remind_read_mem_pages( + compacted: Vec, + source: &[Message], + mem_off: bool, +) -> Vec { + if mem_off { + return compacted; + } + let names = collect_read_page_names(source); + let mut out = strip_reminder(compacted); + if let Some(m) = build_reminder_message(&names) { + out.insert(0, m); + } + out +} + +/// L'événement `tool_used` du journal (forme de `generate`) : le corps en +/// cours de frappe, le diff, la capture, l'aperçu et l'id du résultat complet +/// seulement quand ils sont là. +pub fn tool_used_delta(t: &ToolUsedEvent) -> Map { + let mut tu = Map::new(); + tu.insert("name".into(), t.name.clone().into()); + tu.insert("label".into(), t.label.clone().into()); + tu.insert("result".into(), t.result.clone().into()); + tu.insert("done".into(), t.done.into()); + tu.insert("typing".into(), t.typing.into()); + if !t.body.is_empty() { + tu.insert("body".into(), t.body.clone().into()); + tu.insert("body_lines".into(), t.body_lines.into()); + if t.body_tail { + tu.insert("body_tail".into(), true.into()); + } + } + if !t.diff.is_empty() { + tu.insert("diff".into(), json!(t.diff)); + tu.insert("added".into(), t.added.into()); + tu.insert("removed".into(), t.removed.into()); + } + if !t.image.is_empty() { + tu.insert("image".into(), t.image.clone().into()); + } + if !t.shot.is_empty() { + tu.insert("shot".into(), t.shot.clone().into()); + } + if t.result_chars > 0 { + tu.insert("result_chars".into(), t.result_chars.into()); + } + if !t.result_id.is_empty() { + tu.insert("result_id".into(), t.result_id.clone().into()); + } + let mut d = Map::new(); + d.insert("tool_used".into(), Value::Object(tu)); + d +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::compact::{THINK_NUDGE_FIRST, THINK_NUDGE_STUCK}; + use crate::chat::stream::retry_corrective; + use crate::chat::types::{DiffLine, ToolCall, ToolCallFunc}; + use crate::chat::wire::PROJECT_CONTEXT_PREFIX; + + fn msg(role: &str, s: &str) -> Message { + Message { + role: role.into(), + content: Some(json!(s)), + ..Default::default() + } + } + fn um(s: &str) -> Message { + msg("user", s) + } + fn am(s: &str) -> Message { + msg("assistant", s) + } + fn tm(s: &str) -> Message { + msg("tool", s) + } + fn atc(name: &str) -> Message { + Message { + role: "assistant".into(), + tool_calls: vec![ToolCall { + id: "c1".into(), + kind: "function".into(), + function: ToolCallFunc { + name: name.into(), + arguments: "{}".into(), + }, + ..Default::default() + }], + ..Default::default() + } + } + fn read(id: &str, file: &str) -> Message { + Message { + role: "assistant".into(), + tool_calls: vec![ToolCall { + id: id.into(), + function: ToolCallFunc { + name: "mem_read".into(), + arguments: format!("{{\"file\":\"{file}\"}}"), + }, + ..Default::default() + }], + ..Default::default() + } + } + fn res(id: &str, s: &str) -> Message { + Message { + role: "tool".into(), + tool_call_id: id.into(), + content: Some(json!(s)), + ..Default::default() + } + } + + #[test] + fn prefixe_fusionne_ou_pose_en_tete() { + let sys = msg("system", "/sys perso"); + let out = inject_prefix(vec![sys, um("q")], "préambule"); + assert_eq!(out[0], msg("system", "préambule\n\n/sys perso")); + assert_eq!(out.len(), 2); + let proj = msg("system", &format!("{PROJECT_CONTEXT_PREFIX} x")); + let out = inject_prefix(vec![proj.clone(), um("q")], "préambule"); + assert_eq!( + (out.len(), &out[1]), + (3, &proj), + "un message projet n'absorbe pas le préambule" + ); + assert_eq!(inject_prefix(vec![um("q")], ""), vec![um("q")]); + } + + #[test] + fn historique_depuis_la_vue() { + let rem = msg( + "system", + &format!("{MEM_REMINDER_PREFIX} Memory pages you read earlier: a.md"), + ); + let sys = msg("system", "/sys perso"); + let proj = msg("system", &format!("{PROJECT_CONTEXT_PREFIX} x")); + let u = um("q"); + let pre = msg("system", "préambule"); + let cases = [ + ( + "injectés seuls", + vec![pre.clone(), proj.clone(), u.clone()], + vec![u.clone()], + vec![u.clone()], + ), + ( + "rappel gardé", + vec![pre.clone(), proj.clone(), rem.clone(), u.clone()], + vec![rem.clone(), u.clone()], + vec![rem.clone(), u.clone()], + ), + ( + "système fusionné rendu", + vec![msg("system", "préambule\n\n/sys perso"), u.clone()], + vec![sys.clone(), u.clone()], + vec![sys.clone(), u.clone()], + ), + ( + "sans système", + vec![u.clone()], + vec![u.clone()], + vec![u.clone()], + ), + ]; + for (name, view, hist, want) in cases { + assert_eq!(history_from_view(&view, &hist), want, "{name}"); + } + } + + #[test] + fn rappels_en_porte_a_faux() { + let n = um(THINK_NUDGE_FIRST); + type Case = (&'static str, Vec, Option>); + let cases: Vec = vec![ + ( + "rappel répondu", + vec![um("q"), atc("glob"), tm("r"), n.clone(), am("fini")], + None, + ), + ( + "resté sans réponse", + vec![um("q"), atc("glob"), tm("r"), n.clone()], + Some(vec![um("q"), atc("glob"), tm("r")]), + ), + ( + "ajout juste derrière", + vec![ + um("q"), + atc("glob"), + tm("r"), + n.clone(), + um("et aussi"), + am("fini"), + ], + Some(vec![ + um("q"), + atc("glob"), + tm("r"), + um("et aussi"), + am("fini"), + ]), + ), + ( + "collé derrière la demande", + vec![um("q"), n.clone(), am("fini")], + Some(vec![um("q"), am("fini")]), + ), + ( + "correctif sans réponse", + vec![ + um("q"), + am(""), + um(&retry_corrective("")), + ], + Some(vec![um("q"), am("")]), + ), + ( + "deux rappels en fin", + vec![ + um("q"), + atc("glob"), + tm("r"), + n.clone(), + um(THINK_NUDGE_STUCK), + ], + Some(vec![um("q"), atc("glob"), tm("r")]), + ), + ( + "vraie demande en fin", + vec![um("q"), am("a"), um("suite")], + None, + ), + ]; + for (name, input, want) in cases { + let want = want.unwrap_or_else(|| input.clone()); + assert_eq!(drop_stray_nudges(input), want, "{name}"); + } + } + + #[test] + fn rappel_des_pages_lues() { + let msgs = vec![ + um("applique"), + read("c1", "regles-tache.md"), + res("c1", "RÈGLE 1"), + ]; + let names = collect_read_page_names(&msgs); + assert_eq!(names, ["regles-tache.md"]); + let m = build_reminder_message(&names).unwrap(); + let txt = msg_text(&m); + assert!(txt.contains("regles-tache.md") && txt.contains("mem_read")); + assert_eq!(parse_reminder_names(&txt), names); + // Accumulé à travers les compactions, dédoublonné, sans lecture ratée. + let msgs = vec![ + m.clone(), + um("[CONTEXT COMPACTED] résumé…"), + read("c2", "procedure.md"), + res("c2", "procédure"), + read("c3", "procedure.md"), + res("c3", "v2"), + read("e", "absente.md"), + res("e", "[erreur] page introuvable"), + ]; + assert_eq!( + collect_read_page_names(&msgs), + ["regles-tache.md", "procedure.md"] + ); + // Borné aux plus récentes. + let many: Vec = (0..MEM_REMINDER_MAX_NAMES + 10) + .map(|i| format!("p{i}.md")) + .collect(); + let got = parse_reminder_names(&msg_text(&build_reminder_message(&many).unwrap())); + assert_eq!(got.len(), MEM_REMINDER_MAX_NAMES); + assert_eq!(got.last(), many.last()); + // Reconstruit en tête de l'historique compacté ; rien si mémoire coupée. + let compacted = vec![m.clone(), um("résumé")]; + let out = remind_read_mem_pages(compacted.clone(), &msgs, false); + assert_eq!(out.len(), 2); + assert_eq!( + parse_reminder_names(&msg_text(&out[0])), + ["regles-tache.md", "procedure.md"] + ); + assert_eq!( + remind_read_mem_pages(compacted.clone(), &msgs, true), + compacted + ); + assert_eq!(strip_reminder(vec![m, um("x")]), vec![um("x")]); + } + + #[test] + fn evenement_d_outil_du_journal() { + let d = tool_used_delta(&ToolUsedEvent { + name: "bash".into(), + label: "ls".into(), + ..Default::default() + }); + assert_eq!( + Value::Object(d), + json!({"tool_used": {"name": "bash", "label": "ls", "result": "", "done": false, "typing": false}}) + ); + let d = tool_used_delta(&ToolUsedEvent { + name: "edit".into(), + done: true, + body: "x".into(), + body_lines: 1, + body_tail: true, + diff: vec![DiffLine { + op: "+".into(), + text: "x".into(), + }], + added: 1, + result_chars: 3000, + result_id: "c1.ab".into(), + ..Default::default() + }); + let tu = &d["tool_used"]; + assert_eq!(tu["body_tail"], true); + assert_eq!(tu["diff"], json!([{"op": "+", "text": "x"}])); + assert_eq!( + (tu["added"].clone(), tu["removed"].clone()), + (json!(1), json!(0)) + ); + assert_eq!( + (tu["result_chars"].clone(), tu["result_id"].clone()), + (json!(3000), json!("c1.ab")) + ); + assert!(tu.get("image").is_none() && tu.get("shot").is_none()); + } +} diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs new file mode 100644 index 0000000..0a78b28 --- /dev/null +++ b/rust/loki-rs/src/chat/host.rs @@ -0,0 +1,610 @@ +//! Le tour branché sur le vrai monde : le [`TurnHost`] de la façade. +//! +//! - envoi au moteur par [`EngineHttp`] (clé de CET endpoint), pannes +//! classées et formulées comme `friendlyLLMError` ; +//! - outils fichiers et shell exécutés ici ([`crate::tools`], état partagé +//! tenu par le Go) ; les autres par le Go (`/internal/v1/tool/exec`), avec +//! ce qu'il tire du résultat (capture de la bulle, aperçu du navigateur, +//! image relayée) ; +//! - résultats complets (« voir plus ») confiés à l'écrivain des discussions ; +//! - compaction par [`Compactor`] : résumé demandé au moteur +//! ([`EngineSummarizer`]), blocs archivés pour recall par le Go +//! ([`StoreRecall`]) ; +//! - images jointes (`loki-img:…`) lues sur disque et ouvertes par le Go +//! (déchiffrées), gardées en cache comme `chatImageDataURL`. +//! +//! Écarts assumés : le résumé en continuation (COMPACT_CONTINUATION) demande +//! l'état du slot du moteur, tenu par le Go — `slot_holds` rend faux et la +//! compaction résume la transcription. Ni conseil de cache ni garde +//! d'écriture du mode Code (état du Go) : `cache_hint` et `preflight` absents. + +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use futures_util::StreamExt; +use serde_json::{Map, Value, json}; + +use super::compact::{ + CompactCaps, CompactConfig, CompactMemory, CompactOpts, Compactor, ContinuationRequest, + RecallArchive, Summarizer, SummaryChoice, SummaryError, TranscriptRequest, compact_opts_mid, + parse_summary_response, +}; +use super::engine::{EngineHttp, classify, friendly_error}; +use super::persist::{Persister, StoreSink, ToolResJob}; +use super::stream::{CacheHintFn, PreflightFn, RetryPatterns}; +use super::turn::{CompactPhase, Reply, SendError, ToolOutcome, TurnHost}; +use super::types::{AskEvent, DiffLine, Message, Tool}; +use super::wire::{ + ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME, + build_chat_payload, content_parts, part_image_url, wire_messages, +}; +use crate::store::Store; + +/// Taille au-delà de laquelle le cache d'images oublie les plus anciennes +/// (`imgCacheMax`). +pub const IMG_CACHE_MAX: usize = 64 << 20; +/// Corps d'un refus de résumé gardé au plus (`postSummary`). +const SUMMARY_ERR_MAX: usize = 500; + +/// Base64 standard (avec remplissage), comme `base64.StdEncoding`. +pub fn b64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 + | (*c.get(1).unwrap_or(&0) as u32) << 8 + | *c.get(2).unwrap_or(&0) as u32; + out.push(T[(n >> 18) as usize & 63] as char); + out.push(T[(n >> 12) as usize & 63] as char); + out.push(if c.len() > 1 { + T[(n >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if c.len() > 2 { + T[n as usize & 63] as char + } else { + '=' + }); + } + out +} + +/// Type MIME d'une image rangée, d'après son extension (`imgExtByMime`). +fn image_mime(name: &str) -> &'static str { + for (ext, mime) in [ + (".png", "image/png"), + (".gif", "image/gif"), + (".webp", "image/webp"), + ] { + if name.ends_with(ext) { + return mime; + } + } + "image/jpeg" +} + +/// Nom d'une image rangée, si la référence est sûre (ni chemin ni `..`). +fn image_name(r: &str) -> Option<&str> { + let name = r.strip_prefix(IMG_REF_SCHEME)?; + (!name.is_empty() && !name.contains(['/', '\\']) && !name.contains("..")).then_some(name) +} + +#[derive(Default)] +struct ImgCacheInner { + map: HashMap, + order: VecDeque, + bytes: usize, +} + +/// Images jointes en data-URL, bornées à [`IMG_CACHE_MAX`] (la plus ancienne +/// part en premier, jamais la dernière). +pub struct ImageCache { + dir: PathBuf, + inner: Mutex, +} + +impl ImageCache { + /// `dir` : `LOKI_HOME/chatimg`. + pub fn new(dir: PathBuf) -> Self { + ImageCache { + dir, + inner: Mutex::new(ImgCacheInner::default()), + } + } + + pub fn get(&self, r: &str) -> Option { + let name = image_name(r)?; + self.inner.lock().ok()?.map.get(name).cloned() + } + + fn put(&self, name: &str, url: String) { + let Ok(mut g) = self.inner.lock() else { return }; + if g.map.contains_key(name) { + return; + } + g.bytes += url.len(); + g.map.insert(name.to_string(), url); + g.order.push_back(name.to_string()); + while g.bytes > IMG_CACHE_MAX && g.order.len() > 1 { + if let Some(old) = g.order.pop_front() + && let Some(u) = g.map.remove(&old) + { + g.bytes -= u.len(); + } + } + } + + /// Lit et ouvre (par le Go) les images référencées pas encore en cache. + /// Une image illisible reste absente : la requête dira « indisponible ». + pub async fn prefetch(&self, store: &Store, msgs: &[Message]) { + for m in msgs { + for p in content_parts(m.content.as_ref()) { + let Some(name) = image_name(part_image_url(p)) else { + continue; + }; + if self.inner.lock().is_ok_and(|g| g.map.contains_key(name)) { + continue; + } + let Ok(raw) = tokio::fs::read(self.dir.join(name)).await else { + continue; + }; + let Ok(b) = store.open(raw).await else { + continue; + }; + self.put( + name, + format!("data:{};base64,{}", image_mime(name), b64(&b)), + ); + } + } + } +} + +/// Le moteur vu de la compaction (`postSummary`). +#[derive(Clone)] +pub struct EngineSummarizer { + pub http: EngineHttp, + pub ep: ChatEndpoint, + pub cfg: HashMap, + pub patterns: Arc, + pub port: u16, +} + +impl EngineSummarizer { + async fn post(&self, payload: Value) -> Result { + let body = serde_json::to_vec(&payload).unwrap_or_default(); + let resp = self + .http + .post(&self.ep.url, &self.ep.key, body) + .await + .map_err(|e| { + SummaryError::Other(friendly_error(classify(&e), &e.to_string(), self.port, "")) + })?; + let status = resp.status().as_u16(); + let raw = resp + .bytes() + .await + .map_err(|e| SummaryError::Other(e.to_string()))?; + if status != 200 { + let b = &raw[..raw.len().min(SUMMARY_ERR_MAX)]; + return Err(SummaryError::Http { + who: if self.ep.external { + "API externe" + } else { + "llama-server" + } + .into(), + status, + body: String::from_utf8_lossy(b).trim().to_string(), + }); + } + parse_summary_response(&raw) + } +} + +impl Summarizer for EngineSummarizer { + async fn summarize_transcript( + &self, + req: TranscriptRequest, + ) -> Result { + let mut p = json!({ + "model": self.ep.model, + "messages": [ + {"role": "system", "content": req.system}, + {"role": "user", "content": req.transcript}, + ], + "stream": false, + "temperature": 0.2, + "max_tokens": req.max_tokens, + "chat_template_kwargs": {"enable_thinking": false}, + }); + // Propre à llama.cpp : une API distante stricte refuse l'argument. + if self.ep.external + && let Some(o) = p.as_object_mut() + { + o.remove("chat_template_kwargs"); + } + self.post(p).await + } + + async fn summarize_continuation( + &self, + req: ContinuationRequest, + ) -> Result { + let mut p = build_chat_payload( + &self.ep, + &req.messages, + 0.2, + &ChatPayloadOpts { + tools: &req.tools, + disable_tools: false, + tool_choice_none: true, + effort: &req.effort, + kwargs: Some(&req.kwargs), + no_sampling: true, + }, + &self.cfg, + &|_| None, + ); + if let Some(o) = p.as_object_mut() { + o.insert("stream".into(), Value::Bool(false)); + o.remove("stream_options"); + o.insert("max_tokens".into(), json!(req.max_tokens)); + } + self.post(p).await + } + + fn slot_holds(&self, _conv: &str) -> bool { + false + } + + fn textual_tool_call(&self, content: &str) -> bool { + self.patterns.textual_tool_call(content) + } +} + +/// Archive recall tenue par le Go. +pub struct StoreRecall(pub Store); + +impl RecallArchive for StoreRecall { + async fn archive(&self, label: &str, role: &str, content: &str) -> Result { + self.0.recall_archive(label, role, content).await + } +} + +/// Ce qui survit d'un tour à l'autre (un exemplaire par processus). +pub struct ChatShared { + pub http: EngineHttp, + pub store: Store, + pub persister: Persister, + pub effort: EffortFallbacks, + pub echo_refusals: EchoRefusals, + pub compact_memory: CompactMemory, + pub images: ImageCache, + pub patterns: Arc, +} + +/// Ce que la compaction sait du tour. +pub struct TurnCompact { + pub cfg: CompactConfig, + pub caps: CompactCaps, + /// Mode Code : critères rendus de la discussion. + pub criteria: String, + /// Discussion du tour (vide : tâche ou sous-agent). + pub main_conv: String, + /// Forme de la requête du tour, pour les options de la compaction en + /// cours de tour. + pub tools: Vec, + pub effort: String, + pub kwargs: Map, +} + +/// Le [`TurnHost`] de la façade. +pub struct GoTurnHost { + pub shared: Arc, + /// Discussion dont relèvent les résultats complets. + pub conv_id: String, + /// Capacités du tour, telles que l'API d'outils les attend (champs Go). + pub caps: Value, + pub cfg: HashMap, + pub port: u16, + pub summarizer: EngineSummarizer, + pub compact: TurnCompact, + /// Messages mis en file par l'utilisateur pendant le tour. + pub queued: Box Vec + Send + Sync>, +} + +/// `toolResID` : « .<24 hex> ». +fn tool_res_id(conv: &str) -> String { + let mut b = [0u8; 12]; + let _ = getrandom::fill(&mut b); + let conv = match conv.trim() { + "" => "nosession", + c => c, + }; + format!("{conv}.{}", hex::encode(b)) +} + +/// Réponse de `/internal/v1/tool/exec` → [`ToolOutcome`]. +pub fn tool_outcome(v: &Value, label: &str) -> ToolOutcome { + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let n = |k: &str| v.get(k).and_then(Value::as_i64).unwrap_or(0); + let mut relay: Option = v + .get("relay") + .and_then(|r| serde_json::from_value(r.clone()).ok()); + if relay.is_none() + && let Some(img) = v.get("vision_img").filter(|i| i.is_object()) + { + relay = Some(see_image_message(label, img.clone())); + } + ToolOutcome { + result: s("result"), + diff: v + .get("diff") + .and_then(|d| serde_json::from_value::>(d.clone()).ok()) + .unwrap_or_default(), + added: n("added"), + removed: n("removed"), + ask: v + .get("ask") + .and_then(|a| serde_json::from_value::(a.clone()).ok()), + image: s("image"), + live_shot: s("shot"), + relay, + } +} + +/// `seeImageMessage` : l'image relayée au modèle. +pub fn see_image_message(label: &str, img: Value) -> Message { + let text = if label.is_empty() { + "Image :".to_string() + } else { + format!("Image demandée ({label}) :") + }; + Message { + role: "user".into(), + content: Some(json!([{"type": "text", "text": text}, img])), + ..Default::default() + } +} + +impl TurnHost for GoTurnHost { + async fn send(&self, ep: &ChatEndpoint, body: Vec) -> Result { + match self.shared.http.post(&ep.url, &ep.key, body).await { + Ok(resp) => { + let status = resp.status().as_u16(); + let body = resp.bytes_stream().map(|r| r.map_err(|e| e.to_string())); + Ok(Reply { + status, + body: Box::pin(body), + }) + } + Err(e) => { + let kind = classify(&e); + Err(SendError { + kind, + message: friendly_error(kind, &e.to_string(), self.port, ""), + }) + } + } + } + + async fn exec_tool(&self, name: &str, args: &Map, label: &str) -> ToolOutcome { + // Outils fichiers et shell : ici (lot 7), l'état partagé restant au Go. + if crate::tools::is_local(name) { + let st = crate::tools::GoToolState { + store: self.shared.store.clone(), + caps: self.caps.clone(), + }; + let code = self + .caps + .get("Code") + .and_then(Value::as_bool) + .unwrap_or(false); + if let Some(out) = crate::tools::exec_local(&st, code, name, args, label).await { + return out; + } + } + match self + .shared + .store + .tool_exec(name, &Value::Object(args.clone()), &self.caps) + .await + { + Ok(v) => tool_outcome(&v, label), + Err(e) => ToolOutcome { + result: format!("[erreur] exécution de l'outil impossible : {e}"), + ..Default::default() + }, + } + } + + fn save_tool_result(&self, result: &str) -> String { + let id = tool_res_id(&self.conv_id); + self.shared.persister.enqueue_tool_res(ToolResJob { + key: id.clone(), + plain: Arc::from(result), + }); + id + } + + async fn compact(&self, phase: CompactPhase, msgs: &[Message]) -> Option> { + let recall = StoreRecall(self.shared.store.clone()); + // Mode Code : l'état des critères au moment du résumé (le Go les relit + // aussi à chaque compaction — l'outil criteria a pu les changer). + let criteria = if self.compact.caps.code { + live_criteria(&self.shared.store, &self.compact.main_conv).await + } else { + self.compact.criteria.clone() + }; + let c = Compactor { + cfg: &self.compact.cfg, + summarizer: &self.summarizer, + recall: &recall, + memory: &self.shared.compact_memory, + criteria: &criteria, + }; + let (out, changed) = match phase { + CompactPhase::Reactive | CompactPhase::FullWindow => { + c.compact_messages(msgs, self.compact.caps).await + } + CompactPhase::Mid { used, rescue } => { + let conv = self.compact.main_conv.as_str(); + let opt: CompactOpts = compact_opts_mid( + &self.compact.cfg, + &self.shared.compact_memory, + self.summarizer.slot_holds(conv), + self.summarizer.ep.external, + conv, + msgs, + used, + self.compact.tools.clone(), + &self.compact.effort, + self.compact.kwargs.clone(), + rescue, + |m| { + wire_messages(m, false, &self.compact.cfg.echo) + .0 + .into_owned() + }, + ); + c.compact_messages_noted(msgs, self.compact.caps, &opt) + .await + } + }; + changed.then_some(out) + } + + fn compact_refused(&self, used: i64, msgs: &[Message]) -> bool { + self.shared.compact_memory.refused_skip( + &self.compact.cfg, + &self.compact.main_conv, + used, + msgs, + ) + } + + fn drain_queued(&self) -> Vec { + (self.queued)() + } + + async fn prepare_images(&self, msgs: &[Message]) { + self.shared.images.prefetch(&self.shared.store, msgs).await; + } + + fn image_data_url(&self, r: &str) -> Option { + self.shared.images.get(r) + } + + fn effort_remember(&self, want: &str, got: &str) { + self.shared.effort.remember(&self.cfg, want, got); + } + + fn echo_refuse(&self, model: &str) { + self.shared.echo_refusals.refuse(model); + } + + fn cache_hint(&self) -> Option { + None + } + + fn preflight(&self) -> Option { + None + } +} + +/// Les critères du mode Code rendus pour le résumé ("" : aucun). +pub(crate) async fn live_criteria(store: &crate::store::Store, conv_id: &str) -> String { + super::verify::crit_render(&super::verify::crit_list(store, conv_id).await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_standard() { + assert_eq!(b64(b""), ""); + assert_eq!(b64(b"f"), "Zg=="); + assert_eq!(b64(b"fo"), "Zm8="); + assert_eq!(b64(b"foo"), "Zm9v"); + assert_eq!(b64(b"foobar"), "Zm9vYmFy"); + assert_eq!(b64(&[0xff, 0xfe, 0x00]), "//4A"); + } + + #[test] + fn references_d_images() { + assert_eq!(image_name("loki-img:ab.png"), Some("ab.png")); + for bad in [ + "loki-img:", + "loki-img:../x", + "loki-img:a/b", + "loki-img:a\\b", + "data:x", + "ab.png", + ] { + assert_eq!(image_name(bad), None, "{bad}"); + } + assert_eq!(image_mime("a.png"), "image/png"); + assert_eq!(image_mime("a.webp"), "image/webp"); + assert_eq!(image_mime("a.jpg"), "image/jpeg"); + assert_eq!(image_mime("a.img"), "image/jpeg"); + } + + #[test] + fn cache_d_images_borne() { + let c = ImageCache::new(PathBuf::from("/nulle-part")); + let big = "x".repeat(IMG_CACHE_MAX / 2 + 1); + c.put("a.png", big.clone()); + c.put("b.png", big.clone()); + assert_eq!(c.get("loki-img:a.png"), None, "la plus ancienne part"); + assert_eq!(c.get("loki-img:b.png").map(|u| u.len()), Some(big.len())); + // Une seule image, même trop grosse, reste. + let c = ImageCache::new(PathBuf::from("/nulle-part")); + c.put("g.png", "y".repeat(IMG_CACHE_MAX + 1)); + assert!(c.get("loki-img:g.png").is_some()); + } + + #[test] + fn reponse_de_l_api_d_outils() { + let v = json!({ + "label": "ls", "result": "a\nb", + "diff": [{"op": "+", "text": "x"}], "added": 1, "removed": 2, + "ask": {"question": "On continue ?", "options": ["oui"]}, + "image": "captures/a.png", "shot": "s1", + "relay": {"role": "user", "content": [{"type": "text", "text": "Voici la capture demandée."}]} + }); + let o = tool_outcome(&v, "ls"); + assert_eq!(o.result, "a\nb"); + assert_eq!((o.added, o.removed, o.diff.len()), (1, 2, 1)); + assert_eq!(o.ask.unwrap().question, "On continue ?"); + assert_eq!( + (o.image.as_str(), o.live_shot.as_str()), + ("captures/a.png", "s1") + ); + assert_eq!(o.relay.unwrap().role, "user"); + // vision_img seul : le message relayé est bâti ici (seeImageMessage). + let img = json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}); + let o = tool_outcome(&json!({"result": "ok", "vision_img": img}), "photo.png"); + assert_eq!( + o.relay.unwrap().content, + Some(json!([{"type": "text", "text": "Image demandée (photo.png) :"}, img])) + ); + assert_eq!( + see_image_message("", json!({})).content.unwrap()[0]["text"], + "Image :" + ); + assert_eq!(tool_outcome(&json!({}), "").result, ""); + } + + #[test] + fn identifiant_d_un_resultat_complet() { + let id = tool_res_id("c42"); + let (conv, rand) = id.split_once('.').unwrap(); + assert_eq!((conv, rand.len()), ("c42", 24)); + assert!(tool_res_id(" ").starts_with("nosession.")); + assert_ne!(tool_res_id("c"), tool_res_id("c")); + } +} diff --git a/rust/loki-rs/src/chat/lease.rs b/rust/loki-rs/src/chat/lease.rs new file mode 100644 index 0000000..f17fe44 --- /dev/null +++ b/rust/loki-rs/src/chat/lease.rs @@ -0,0 +1,212 @@ +//! Bail du moteur — le verrou de génération du Go (`conv.Generating`), pris +//! par une conversation tenue par Rust (`web_internal_lease.go`). +//! +//! Le verrou est partagé avec les tâches planifiées, le bench et l'optimiseur, +//! qui restent au Go : un tour Rust doit le tenir du début à la fin, sinon une +//! tâche partirait vers le même moteur en plein tour. +//! +//! [`EngineLease::acquire`] prend le bail et le renouvelle en tâche de fond +//! (au tiers de sa durée). Le Go le rompt (Reset, bascule) ou y signale un +//! stop : [`EngineLease::interrupted`] le dit au tour. Une façade tuée ne +//! renouvelle plus : le Go rend le verrou à l'expiration. [`EngineLease::release`] +//! le rend tout de suite ; un bail lâché sans `release` est rendu en tâche de +//! fond. + +use std::time::Duration; + +use hyper::StatusCode; +use serde_json::{Value, json}; +use tokio::sync::watch; +use tokio::task::JoinHandle; + +use crate::store::Store; + +/// Durée d'un bail par défaut (`extLeaseDefaultTTL`). +pub const LEASE_TTL: Duration = Duration::from_secs(30); + +const PATH_LEASE: &str = "/internal/v1/engine/lease"; +const PATH_RENEW: &str = "/internal/v1/engine/lease/renew"; +const PATH_RELEASE: &str = "/internal/v1/engine/lease/release"; +const PATH_STOP: &str = "/internal/v1/engine/stop"; + +/// Refus de prendre le bail. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LeaseError { + /// Le moteur est tenu (tour, tâche planifiée, bench, optimiseur). `error` + /// est le message du Go (`busyReason`) ; la tâche est nommée s'il s'agit + /// d'une tâche planifiée. + Busy { + error: String, + task_id: Option, + task_name: Option, + }, + /// Go injoignable ou réponse inattendue. + Other(String), +} + +impl std::fmt::Display for LeaseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LeaseError::Busy { error, .. } => f.write_str(error), + LeaseError::Other(e) => write!(f, "bail du moteur : {e}"), + } + } +} + +/// État du bail vu du tour. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeaseSignal { + Held, + /// Stop demandé côté Go : le tour s'arrête, le bail tient jusqu'à `release`. + Stop, + /// Bail rompu (Reset ou bascule côté Go, ou expiré) : le verrou n'est plus + /// à nous. + Lost, +} + +/// Le bail tenu ; rendu par [`Self::release`] ou à la destruction. +pub struct EngineLease { + id: String, + store: Store, + signal: watch::Receiver, + renew: JoinHandle<()>, + released: bool, +} + +impl EngineLease { + /// Prend le verrou de génération pour `ttl` (borné par le Go à 5 s – 5 min). + pub async fn acquire(store: Store, ttl: Duration) -> Result { + let body = json!({ "ttl_ms": ttl.as_millis() as u64 }); + let (status, v) = store + .internal_post(PATH_LEASE, &body) + .await + .map_err(LeaseError::Other)?; + match status { + StatusCode::OK => {} + StatusCode::CONFLICT => { + let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string); + return Err(LeaseError::Busy { + error: s("error").unwrap_or_else(|| "génération en cours".into()), + task_id: s("task_id"), + task_name: s("task_name"), + }); + } + s => return Err(LeaseError::Other(format!("{PATH_LEASE} : {s} {v}"))), + } + let id = v + .get("lease") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| LeaseError::Other(format!("{PATH_LEASE} : réponse sans bail {v}")))? + .to_string(); + let granted = v + .get("ttl_ms") + .and_then(Value::as_u64) + .map(Duration::from_millis) + .unwrap_or(ttl); + let (tx, rx) = watch::channel(LeaseSignal::Held); + let renew = tokio::spawn(renew_loop(store.clone(), id.clone(), granted / 3, tx)); + Ok(EngineLease { + id, + store, + signal: rx, + renew, + released: false, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + /// État courant (dernier renouvellement). + pub fn signal(&self) -> LeaseSignal { + *self.signal.borrow() + } + + /// Se résout au premier stop ou à la perte du bail, et rend lequel. + pub async fn interrupted(&mut self) -> LeaseSignal { + let got = self + .signal + .wait_for(|s| *s != LeaseSignal::Held) + .await + .map(|s| *s); + match got { + Ok(s) => s, + // Tâche de renouvellement terminée sans signal : rien à attendre. + Err(_) => std::future::pending().await, + } + } + + /// Rend le verrou maintenant. + pub async fn release(mut self) { + self.released = true; + self.renew.abort(); + release(&self.store, &self.id).await; + } +} + +impl Drop for EngineLease { + fn drop(&mut self) { + self.renew.abort(); + if self.released { + return; + } + // Lâché sans release (tour annulé, panique) : rendu en tâche de fond. + // Hors runtime, l'expiration côté Go s'en charge. + if let Ok(rt) = tokio::runtime::Handle::try_current() { + let (store, id) = (self.store.clone(), std::mem::take(&mut self.id)); + rt.spawn(async move { release(&store, &id).await }); + } + } +} + +async fn release(store: &Store, id: &str) { + if let Err(e) = store + .internal_post(PATH_RELEASE, &json!({ "lease": id })) + .await + { + eprintln!("[lease] libération du bail échouée (le Go le rendra à l'expiration) : {e}"); + } +} + +/// Renouvelle jusqu'à la perte du bail. Une erreur réseau n'arrête pas la +/// boucle : le prochain essai peut passer avant l'expiration. +async fn renew_loop(store: Store, id: String, every: Duration, tx: watch::Sender) { + let mut tick = tokio::time::interval(every.max(Duration::from_millis(100))); + tick.tick().await; // le premier top est immédiat : le bail vient d'être pris + loop { + tick.tick().await; + match store + .internal_post(PATH_RENEW, &json!({ "lease": id })) + .await + { + Ok((StatusCode::OK, v)) => { + if v.get("stop").and_then(Value::as_bool) == Some(true) { + tx.send_if_modified(|s| { + let was = *s; + if was == LeaseSignal::Held { + *s = LeaseSignal::Stop; + } + was == LeaseSignal::Held + }); + } + } + Ok((StatusCode::GONE, _)) => { + tx.send_replace(LeaseSignal::Lost); + return; + } + Ok((s, v)) => eprintln!("[lease] renouvellement : {s} {v}"), + Err(e) => eprintln!("[lease] renouvellement : {e}"), + } + } +} + +/// Arrête le détenteur Go du moteur (tâche planifiée, bench, optimiseur) : +/// le bouton stop du chat, quand ce n'est pas un tour Rust qui tourne. +pub async fn engine_stop(store: &Store) -> Result<(), String> { + match store.internal_post(PATH_STOP, &json!({})).await? { + (StatusCode::NO_CONTENT, _) => Ok(()), + (s, v) => Err(format!("{PATH_STOP} : {s} {v}")), + } +} diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index 71db738..3a6725c 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -7,11 +7,31 @@ //! - [`stream`] : analyse du flux SSE du moteur (texte, raisonnement, appels //! d'outils, statistiques) ; //! - [`compact`] : estimation du contexte et compactage de l'historique ; -//! - [`journal`] : journal d'affichage (compactage, rejeu, pagination). +//! - [`journal`] : journal d'affichage (compactage, rejeu, pagination) ; +//! - [`conv`] : la discussion en mémoire (état, epoch, verrou du tour, +//! abonnés) ; +//! - [`persist`] : format en base, index des discussions, écrivain unique ; +//! - [`sessions`] : plusieurs discussions (index, bascules, chargement) ; +//! - [`lease`] : bail du moteur (verrou de génération partagé avec le Go) ; +//! - [`nudges`] : budget d'outils, rappels, appels répétés, aperçus ; +//! - [`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). pub mod compact; +pub mod conv; pub mod engine; +pub mod generate; +pub mod history; +pub mod host; pub mod journal; +pub mod lease; +pub mod nudges; +pub mod persist; +pub mod sessions; pub mod stream; +pub mod turn; pub mod types; +pub mod verify; pub mod wire; diff --git a/rust/loki-rs/src/chat/nudges.rs b/rust/loki-rs/src/chat/nudges.rs new file mode 100644 index 0000000..21248d7 --- /dev/null +++ b/rust/loki-rs/src/chat/nudges.rs @@ -0,0 +1,227 @@ +//! Petites règles de la boucle d'un tour — port de `llm_budget.go`, +//! `appendNudge` (`llm_injected.go`), `steerSystem`, `dedupableTool`, +//! `repeatedCallResult` et `toolResultPreview` (`llm_client.go`). + +use serde_json::Value; + +use super::types::Message; + +pub(crate) use super::compact::{THINK_NUDGE_FIRST, THINK_NUDGE_STUCK}; + +/// Palier par défaut du budget souple d'appels d'outils (`agentBudgetDefault`). +pub const AGENT_BUDGET_DEFAULT: i64 = 24; +/// Multiplicateur du palier en mode Code (`codeBudgetFactor`). +pub const CODE_BUDGET_FACTOR: i64 = 3; +/// Relances « pensé sans agir » d'un tour (`maxNudges`). +pub const MAX_NUDGES: i64 = 2; +/// Aperçu d'un résultat d'outil envoyé à l'UI, en caractères (`toolPreviewChars`). +pub const TOOL_PREVIEW_CHARS: usize = 1600; + +/// `agentBudget` : palier AGENT_BUDGET ; 0 ou off/false/no/non/disable(d) +/// coupe le mécanisme ; illisible → défaut. +pub fn agent_budget(v: &str) -> i64 { + match v.trim().to_lowercase().as_str() { + "" => AGENT_BUDGET_DEFAULT, + "off" | "false" | "no" | "non" | "disable" | "disabled" => 0, + s => s + .parse::() + .ok() + .filter(|n| *n >= 0) + .unwrap_or(AGENT_BUDGET_DEFAULT), + } +} + +/// `budgetNudge` : le rappel à injecter quand le tour franchit un palier +/// (budget, 2×budget…), ou "" : `calls` appels faits, `sent` rappels déjà +/// envoyés. Une pression, pas une barrière. +pub fn budget_nudge(calls: i64, budget: i64, sent: i64) -> String { + if budget <= 0 || calls < budget * (sent + 1) { + return String::new(); + } + let n = format!("{calls} tool calls"); + match sent { + 0 => format!( + "[system] You have made {n} in this single turn. That is a lot: you almost certainly have enough to answer already. Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing." + ), + 1 => format!( + "[system] {n} now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further." + ), + _ => format!( + "[system] {n}. Stop. Write your final answer in this message. Do not call another tool." + ), + } +} + +/// `logBudget`. +pub fn log_budget(calls: i64, budget: i64, sent: i64) { + eprintln!( + "[agent] {calls} appels d'outils sur ce tour (palier={budget}) — rappel n°{sent} envoyé au modèle" + ); +} + +/// `appendNudge` : le rappel part en fin de fil ET dans l'historique (le tour +/// suivant le renvoie à l'identique) — sauf juste après un message `user`, +/// où deux `user` d'affilée n'entrent pas dans l'historique. +pub fn append_nudge(messages: &mut Vec, extra: &mut Vec, text: &str) { + let m = Message { + role: "user".into(), + content: Some(Value::String(text.into())), + ..Default::default() + }; + let after_user = messages.last().is_some_and(|l| l.role == "user"); + messages.push(m.clone()); + if !after_user { + extra.push(m); + } +} + +/// `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. +pub fn steer_system(msgs: &[Message], hint: &str) -> Vec { + if let Some(first) = msgs.first() + && first.role == "system" + && let Some(Value::String(existing)) = &first.content + { + let mut out = msgs.to_vec(); + out[0] = Message { + role: "system".into(), + content: Some(Value::String(format!("{existing}\n\n{hint}"))), + ..Default::default() + }; + return out; + } + let mut out = Vec::with_capacity(msgs.len() + 1); + out.push(Message { + role: "system".into(), + content: Some(Value::String(hint.into())), + ..Default::default() + }); + out.extend_from_slice(msgs); + out +} + +/// `dedupableTool` : un appel identique se court-circuite, sauf bash et ses +/// jobs (effets de bord), see_image et le navigateur (état vivant, image à +/// part). +pub fn dedupable_tool(name: &str) -> bool { + !matches!(name, "bash" | "bash_bg" | "bash_tail" | "see_image") && !name.starts_with("browser_") +} + +/// `repeatedCallResult` : réponse à un appel RIGOUREUSEMENT identique déjà +/// exécuté dans le tour — la note en tête, puis plus de charge utile du tout. +pub fn repeated_call_result(prev: &str, repeats: i64) -> String { + if repeats >= 2 { + return format!( + "[déjà fait] Cet appel exact a déjà été exécuté {repeats} fois dans ce tour ; son résultat est plus haut dans la conversation. Ne le redemande plus : réponds avec ce que tu as, ou change d'approche (autre URL, autres arguments, web_grep pour cibler)." + ); + } + format!( + "[déjà fait] Appel identique déjà exécuté dans ce tour — non rejoué. Voici à nouveau son résultat ; ne le redemande pas une troisième fois.\n\n{prev}" + ) +} + +/// `toolResultPreview` : (aperçu, taille réelle en caractères, coupé ?). +pub fn tool_result_preview(s: &str) -> (&str, usize, bool) { + let n = s.chars().count(); + if n <= TOOL_PREVIEW_CHARS { + return (s, n, false); + } + let end = s + .char_indices() + .nth(TOOL_PREVIEW_CHARS) + .map_or(s.len(), |(i, _)| i); + (&s[..end], n, true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::compact::is_loki_injected; + use serde_json::json; + + fn um(s: &str) -> Message { + Message { + role: "user".into(), + content: Some(json!(s)), + ..Default::default() + } + } + + #[test] + fn budget() { + assert_eq!(agent_budget(""), 24); + assert_eq!(agent_budget(" Off "), 0); + assert_eq!(agent_budget("10"), 10); + assert_eq!(agent_budget("-3"), 24); + assert_eq!(agent_budget("x"), 24); + assert_eq!(budget_nudge(23, 24, 0), ""); + assert_eq!(budget_nudge(100, 0, 0), ""); + let first = budget_nudge(24, 24, 0); + assert!(first.contains("24 tool calls") && first.starts_with("[system] ")); + assert_eq!(budget_nudge(47, 24, 1), ""); + assert!(budget_nudge(48, 24, 1).contains("going in circles")); + assert!(budget_nudge(72, 24, 2).ends_with("Do not call another tool.")); + // Les rappels sont reconnus comme venant de loki (titre, compaction…). + for sent in 0..3 { + assert!(is_loki_injected(&um(&budget_nudge( + 24 * (sent + 1), + 24, + sent + )))); + } + } + + #[test] + fn rappel_en_fin_de_fil() { + let (mut msgs, mut extra) = (vec![um("q")], vec![]); + append_nudge(&mut msgs, &mut extra, "a"); + assert_eq!( + (msgs.len(), extra.len()), + (2, 0), + "après un user : hors historique" + ); + let tool = Message { + role: "tool".into(), + content: Some(json!("r")), + ..Default::default() + }; + msgs.push(tool); + append_nudge(&mut msgs, &mut extra, "b"); + assert_eq!((msgs.len(), extra.len()), (4, 1)); + } + + #[test] + fn consigne_systeme_en_tete() { + let sys = Message { + role: "system".into(), + content: Some(json!("base")), + ..Default::default() + }; + let out = steer_system(&[sys, um("q")], "h"); + assert_eq!(out[0].content, Some(json!("base\n\nh"))); + assert_eq!(out.len(), 2); + let out = steer_system(&[um("q")], "h"); + assert_eq!((out[0].role.as_str(), out.len()), ("system", 2)); + } + + #[test] + fn appels_repetes() { + assert!(dedupable_tool("edit") && dedupable_tool("web_fetch")); + assert!(!dedupable_tool("bash") && !dedupable_tool("browser_click")); + assert!(repeated_call_result("R", 1).ends_with("\n\nR")); + let r = repeated_call_result("R", 2); + assert!(r.contains("2 fois") && !r.ends_with("\n\nR")); + } + + #[test] + fn apercu() { + assert_eq!(tool_result_preview("abc"), ("abc", 3, false)); + let long = "é".repeat(TOOL_PREVIEW_CHARS + 5); + let (p, n, cut) = tool_result_preview(&long); + assert_eq!( + (p.chars().count(), n, cut), + (TOOL_PREVIEW_CHARS, TOOL_PREVIEW_CHARS + 5, true) + ); + } +} diff --git a/rust/loki-rs/src/chat/persist.rs b/rust/loki-rs/src/chat/persist.rs new file mode 100644 index 0000000..f7eebed --- /dev/null +++ b/rust/loki-rs/src/chat/persist.rs @@ -0,0 +1,1436 @@ +//! Persistance des discussions — port de `chat_persist.go` et de la partie +//! « index » de `chat_sessions.go`. +//! +//! Même format en base que le Go, pour qu'une discussion écrite par l'un se +//! relise par l'autre (et pour la comparaison Go / Rust du lot 6) : +//! +//! ```text +//! chat/index → liste des discussions (ConvMeta, sans les messages), scellée +//! chat/active → identifiant de la discussion ouverte (en clair) +//! chat/conv: → état complet d'une discussion (ConvState), scellé +//! toolres/. → résultat complet d'un outil (« voir plus »), scellé +//! ``` +//! +//! Trois parties : +//! - le FORMAT : [`ConvState`] (JSON de `Conversation`), son instantané +//! [`take_snapshot`] et sa relecture tolérante [`decode_state`] ; +//! - l'INDEX : [`ConvMeta`], titres ([`conv_summary`]), tri, projets, +//! rafraîchissement d'une entrée ([`touch_meta_in`]) ; +//! - l'ÉCRIVAIN UNIQUE : [`Persister`], qui sort l'écriture du chemin du +//! premier jeton, fusionne ce qui s'accumule (dernier instantané de chaque +//! discussion + résultats d'outils) en un lot, n'écrit jamais un état plus +//! ancien qu'un état déjà écrit et ne fait jamais renaître une discussion +//! supprimée. Le lot part dans un [`PersistSink`] ; [`StoreSink`] l'écrit +//! par l'API interne du Go ([`crate::store::Store`]), scellé par lui. +//! +//! Écarts assumés avec le Go : +//! - une seule base : le chemin (`path`, `withDBAt`) disparaît des instantanés ; +//! - pas de transaction bbolt à travers l'API interne : [`StoreSink`] écrit les +//! résultats, puis chaque contenu, puis l'index. Un échec au milieu laisse +//! des contenus à jour sous un index en retard — le prochain instantané le +//! rattrape ([`touch_meta_in`] ajoute une entrée absente). Tant que la +//! conversation est tenue par Rust, le Go ne touche plus à l'index ; +//! - images en base64 → références (`refImagesInMessages`) : à faire par +//! l'appelant avant [`take_snapshot`], qui ne touche pas au disque ; +//! - relecture : un champ mal typé est ignoré seul (comme `json.Unmarshal`) ; +//! un message ou un événement mal typé est écarté entier (le Go le garderait +//! à moitié rempli). + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use futures_util::FutureExt; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::Value; +use tokio::sync::watch; + +use super::compact::{IMAGE_LOST_MARKER, is_loki_injected, without_ctx_update}; +use super::journal::compact_log; +use super::types::{LogEvent, Message}; +use crate::store::{CryptError, Store}; + +// ---- clés en base ---------------------------------------------------------- + +/// Bucket des discussions (`bkChat`). +pub const BK_CHAT: &str = "chat"; +/// Bucket des résultats d'outils complets (`bkToolRes`). +pub const BK_TOOL_RES: &str = "toolres"; +/// Liste des discussions (`ckIndex`). +pub const CK_INDEX: &str = "index"; +/// Discussion ouverte (`ckActive`). +pub const CK_ACTIVE: &str = "active"; +/// Fil unique d'avant le multi-discussions (`ckLegacy`). +pub const CK_LEGACY: &str = "conversation"; +/// Projet d'une discussion qui n'en a pas (`defaultProjectSlug`). +pub const DEFAULT_PROJECT_SLUG: &str = "generale"; + +/// Délai d'attente de l'écrivain avant une sortie (`persistExitWait`). +pub const PERSIST_EXIT_WAIT: Duration = Duration::from_secs(6); + +/// Longueur d'un titre déduit, en caractères (`convSummary`). +const TITLE_RUNES: usize = 60; + +/// `convKey` : clé du contenu d'une discussion. +pub fn conv_key(id: &str) -> String { + format!("conv:{id}") +} + +/// `newConvID` : horodatage en nanosecondes — monotone, lisible au tri. +pub fn new_conv_id() -> String { + let ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("c{ns}") +} + +/// `toolResConv` : identifiant de discussion d'une clé « . ». +pub fn tool_res_conv(key: &str) -> &str { + match key.rfind('.') { + Some(i) if i > 0 => &key[..i], + _ => key, + } +} + +pub(crate) fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +// ---- format : état d'une discussion ---------------------------------------- + +/// Bloc projet figé d'une discussion (`projSnapshot`, PROJ_SNAPSHOT). Porté +/// tel quel : la façade ne le calcule pas encore, elle le garde intact. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ProjSnapshot { + #[serde(default)] + pub at: i64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub msgs: Vec, + #[serde(default)] + pub state: ProjState, + #[serde(default)] + pub key: String, + #[serde(default)] + pub sys_hash: String, + #[serde(default, skip_serializing_if = "is_zero")] + pub snap_tok: i64, + #[serde(default, skip_serializing_if = "is_zero")] + pub delta_tok: i64, +} + +/// Dernier état du projet annoncé au modèle (`projState`). Les tables sont +/// triées par clé, comme une `map` Go marshalée. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ProjState { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub day: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub env: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub code: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub desc: String, + #[serde(default, skip_serializing_if = "is_false")] + pub mem_on: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mem_prose: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub mem: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub trk: BTreeMap, +} + +fn is_zero(v: &i64) -> bool { + *v == 0 +} +fn is_false(b: &bool) -> bool { + !*b +} + +/// Ce qui est persisté d'une `Conversation` : la vue du modèle, le journal +/// d'affichage, le compteur de séquence, le contexte mesuré et le bloc projet. +/// Le reste (génération en cours, epoch, file d'attente…) vit en mémoire. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ConvState { + pub messages: Vec, + pub log: Vec, + pub seq: i64, + pub ctx_used: i64, + pub proj_snap: Option, + /// Messages que `ctx_used` couvre déjà (0 = inconnu) — en mémoire + /// seulement : un rechargement repart du seul `ctx_used`. + pub ctx_used_len: usize, + /// Génération la plus longue du dernier tour (moteur local), pour la garde + /// de marge de la compaction — en mémoire seulement. + pub gen_peak: i64, +} + +/// Forme écrite, empruntée : l'instantané en plein tour écrit un journal +/// compacté sans toucher à celui que suivent les abonnés. +#[derive(Serialize)] +struct StateOut<'a> { + #[serde(serialize_with = "nil_if_empty")] + messages: &'a [Message], + #[serde(serialize_with = "nil_if_empty")] + log: &'a [LogEvent], + seq: i64, + ctx_used: i64, + #[serde(skip_serializing_if = "Option::is_none")] + proj_snap: Option<&'a ProjSnapshot>, +} + +/// Une tranche Go vide (nil) s'écrit `null` : même octets que le Go. +fn nil_if_empty(v: &&[T], s: S) -> Result { + if v.is_empty() { + s.serialize_none() + } else { + v.serialize(s) + } +} + +impl ConvState { + fn out<'a>(&'a self, log: &'a [LogEvent]) -> StateOut<'a> { + StateOut { + messages: &self.messages, + log, + seq: self.seq, + ctx_used: self.ctx_used, + proj_snap: self.proj_snap.as_ref(), + } + } + + /// JSON de la discussion, tel que `json.Marshal(conv)`. + pub fn to_json(&self) -> Vec { + serde_json::to_vec(&self.out(&self.log)).unwrap_or_default() + } +} + +impl Serialize for ConvState { + fn serialize(&self, s: S) -> Result { + self.out(&self.log).serialize(s) + } +} + +/// Relit une discussion persistée — `json.Unmarshal` dans une `Conversation` +/// remise à zéro (`loadFrom`), puis la guérison `stripImageParts`. Contenu +/// vide ou illisible → fil neuf ; un champ mal typé est ignoré seul. +pub fn decode_state(b: &[u8]) -> ConvState { + let mut st = ConvState::default(); + let Ok(Value::Object(mut obj)) = serde_json::from_slice::(b) else { + return st; + }; + if let Some(v) = obj.remove("messages") { + st.messages = decode_list(v); + } + if let Some(v) = obj.remove("log") { + st.log = decode_list(v); + } + if let Some(n) = obj + .remove("seq") + .and_then(|v| serde_json::from_value(v).ok()) + { + st.seq = n; + } + if let Some(n) = obj + .remove("ctx_used") + .and_then(|v| serde_json::from_value(v).ok()) + { + st.ctx_used = n; + } + if let Some(p) = obj + .remove("proj_snap") + .and_then(|v| serde_json::from_value(v).ok()) + { + st.proj_snap = p; + } + strip_image_parts(&mut st.messages); + st +} + +/// Tableau JSON → éléments décodables ; `null` ou autre chose → vide. +fn decode_list(v: Value) -> Vec { + match v { + Value::Array(items) => items + .into_iter() + .filter_map(|x| serde_json::from_value(x).ok()) + .collect(), + _ => Vec::new(), + } +} + +/// `stripImageParts` : un message multimodal persisté (base64 d'une capture +/// d'avant les captures éphémères) est aplati en son texte suivi de +/// [`IMAGE_LOST_MARKER`] — rejoué à chaque tour, il dépassait le contexte. +pub fn strip_image_parts(msgs: &mut [Message]) { + for m in msgs.iter_mut() { + let Some(Value::Array(parts)) = &m.content else { + continue; + }; + let mut texts: Vec<&str> = Vec::new(); + let mut dropped = false; + for p in parts.iter().filter_map(Value::as_object) { + if p.get("type").and_then(Value::as_str) == Some("image_url") { + dropped = true; + continue; + } + if let Some(Value::String(t)) = p.get("text") { + texts.push(t); + } + } + if dropped { + let text = texts.join("\n") + IMAGE_LOST_MARKER; + m.content = Some(Value::String(text)); + } + } +} + +/// Un instantané complet d'une discussion, prêt à écrire (`convSnap`). +#[derive(Debug, Clone, PartialEq)] +pub struct ConvSnap { + /// Discussion à laquelle appartient le contenu. + pub id: String, + /// Ordre de prise : le plus grand l'emporte toujours. + pub seq: u64, + /// JSON de la discussion. + pub body: Vec, + /// Titre déduit du premier message ([`conv_summary`]). + pub title: String, + /// Échanges : événements `user` du journal. + pub turns: i64, + /// Projet d'une entrée d'index à créer. + pub project: String, +} + +/// `snapshot` : instantané de `state` sous la discussion `id`. En plein tour +/// (`generating`), le journal porte encore un événement par jeton : on écrit +/// la forme qu'aura le journal en fin de tour (`compactLog`). `seq` vient de +/// [`Persister::next_seq`], pris sous le même verrou que l'état. +pub fn take_snapshot( + state: &ConvState, + generating: bool, + id: &str, + seq: u64, + project: &str, +) -> ConvSnap { + let body = if generating { + let compacted = compact_log(&state.log); + serde_json::to_vec(&state.out(&compacted)).unwrap_or_default() + } else { + state.to_json() + }; + let turns = state + .log + .iter() + .filter(|ev| ev.delta.contains_key("user")) + .count() as i64; + ConvSnap { + id: id.to_string(), + seq, + body, + title: conv_summary(&state.messages), + turns, + project: project.to_string(), + } +} + +// ---- index des discussions ------------------------------------------------- + +/// Une discussion SANS ses messages : ce que liste l'UI (`convMeta`). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ConvMeta { + #[serde(default)] + pub id: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub created: i64, + #[serde(default)] + pub updated: i64, + #[serde(default)] + pub turns: i64, + /// Slug du projet ; vide sur les discussions d'avant les projets. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub project: String, +} + +/// `convProjectOf` : projet d'une discussion, l'absence valant le projet par +/// défaut — une discussion sans projet ne doit jamais devenir invisible. +pub fn conv_project_of(m: &ConvMeta) -> &str { + match m.project.trim() { + "" => DEFAULT_PROJECT_SLUG, + s => s, + } +} + +/// Index relu (`convIndex`) : JSON déjà ouvert ; illisible → vide. Plus +/// récemment modifiée en tête, tri stable. +pub fn decode_index(b: &[u8]) -> Vec { + let mut idx: Vec = serde_json::from_slice::>>(b) + .ok() + .flatten() + .unwrap_or_default(); + sort_index(&mut idx); + idx +} + +/// Plus récemment modifiée en tête (tri stable, comme `sort.SliceStable`). +pub fn sort_index(idx: &mut [ConvMeta]) { + idx.sort_by_key(|m| std::cmp::Reverse(m.updated)); +} + +/// JSON de l'index, tel que `json.Marshal([]convMeta)` (vide → `null`). +pub fn encode_index(idx: &[ConvMeta]) -> Vec { + if idx.is_empty() { + return b"null".to_vec(); + } + serde_json::to_vec(idx).unwrap_or_default() +} + +/// `convIndexForProject` : les discussions d'un projet. +pub fn index_for_project<'a>(idx: &'a [ConvMeta], slug: &str) -> Vec<&'a ConvMeta> { + idx.iter().filter(|m| conv_project_of(m) == slug).collect() +} + +/// `tagOrphanConversations` : rattache au projet les discussions qui n'en ont +/// pas. Rend vrai si l'index a changé (à réécrire). +pub fn tag_orphans(idx: &mut [ConvMeta], slug: &str) -> bool { + let mut changed = false; + for m in idx.iter_mut().filter(|m| m.project.trim().is_empty()) { + m.project = slug.to_string(); + changed = true; + } + changed +} + +/// Refus d'une opération sur l'index (messages du Go). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IndexError { + /// « identifiant ou titre manquant » + Missing, + /// « discussion introuvable » + NotFound, +} + +impl std::fmt::Display for IndexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + IndexError::Missing => "identifiant ou titre manquant", + IndexError::NotFound => "discussion introuvable", + }) + } +} + +/// `convRename`, sur l'index : titre choisi par l'utilisateur (rogné). +pub fn rename_in(idx: &mut [ConvMeta], id: &str, title: &str) -> Result<(), IndexError> { + let title = title.trim(); + if id.is_empty() || title.is_empty() { + return Err(IndexError::Missing); + } + let m = idx + .iter_mut() + .find(|m| m.id == id) + .ok_or(IndexError::NotFound)?; + m.title = title.to_string(); + Ok(()) +} + +/// Retire une discussion de l'index (`convDelete`) ; faux si absente. +pub fn remove_from(idx: &mut Vec, id: &str) -> bool { + let n = idx.len(); + idx.retain(|m| m.id != id); + idx.len() != n +} + +/// `convTouchMetaIn` : rafraîchit l'entrée d'un instantané (date, échanges, +/// titre déduit si l'utilisateur n'en a pas choisi). Une discussion absente +/// est ajoutée — c'est ce qui recolle une discussion créée mémoire +/// verrouillée. +pub fn touch_meta_in(idx: &mut Vec, s: &ConvSnap, now: i64) { + if let Some(m) = idx.iter_mut().find(|m| m.id == s.id) { + m.updated = now; + m.turns = s.turns; + if m.title.is_empty() { + m.title = s.title.clone(); + } + return; + } + idx.push(ConvMeta { + id: s.id.clone(), + title: s.title.clone(), + created: now, + updated: now, + turns: s.turns, + project: s.project.clone(), + }); +} + +/// `convSummary` : le premier message de l'utilisateur fait le titre (rappels +/// de loki et bloc exclus), 60 caractères au plus. Vide sans +/// message : l'UI affiche « Nouvelle discussion ». +pub fn conv_summary(msgs: &[Message]) -> String { + for m in msgs { + if m.role != "user" || is_loki_injected(m) { + continue; + } + let stripped = without_ctx_update(m); + let m = stripped.as_ref().unwrap_or(m); + let Some(Value::String(s)) = &m.content else { + continue; + }; + let s = s.replace('\n', " "); + let s = s.trim(); + if s.is_empty() { + continue; + } + if s.chars().count() > TITLE_RUNES { + let head: String = s.chars().take(TITLE_RUNES).collect(); + return format!("{}…", head.trim()); + } + return s.to_string(); + } + String::new() +} + +// ---- écrivain unique ------------------------------------------------------- + +/// Un résultat d'outil complet à ranger dans [`BK_TOOL_RES`] (`toolResJob`). +#[derive(Debug, Clone)] +pub struct ToolResJob { + /// « . ». + pub key: String, + pub plain: Arc, +} + +/// Bilan d'un lot : instantanés écrits (discussion, seq) et première erreur. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommitOutcome { + pub wrote: Vec<(String, u64)>, + pub err: Option, +} + +/// Écrit un lot (`persistCommit`). Les résultats d'outils d'abord, chacun +/// sauté si [`ResGate::wanted`] dit qu'une suppression l'a annulé. +pub trait PersistSink: Send + Sync + 'static { + fn commit( + &self, + snaps: Vec, + res: Vec, + gate: ResGate, + ) -> impl Future + Send; +} + +#[derive(Default)] +struct PState { + /// Dernier instantané en attente, par discussion. + snaps: HashMap, + /// Résultats d'outils en attente, dans l'ordre. + res: Vec, + /// Résultats pas encore écrits : « voir plus » les sert d'ici. + mem: HashMap>, + /// Seq du dernier instantané écrit, par discussion. + written: HashMap, + /// Discussions supprimées : plus rien ne doit les réécrire. + deleted: HashSet, + queued: u64, + done: u64, + running: bool, + /// Résultats du lot en cours d'écriture, et ceux qu'une suppression a + /// annulés depuis. + inflight: HashSet, + cancelled: HashSet, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, PState> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Ce que l'écrivain consulte DANS son lot (`toolResWanted`). +#[derive(Clone)] +pub struct ResGate(Arc>); + +impl ResGate { + /// Ce résultat du lot en cours doit-il encore être écrit ? + pub fn wanted(&self, key: &str) -> bool { + !lock(&self.0).cancelled.contains(key) + } +} + +struct Shared { + sink: S, + st: Arc>, + done: watch::Sender, + seq: AtomicU64, +} + +/// L'écrivain unique (`convPersister`). Un seul lot à la fois : l'ordre des +/// instantanés ne dépend que de leur seq. Les méthodes qui confient un +/// travail démarrent l'écrivain sur le runtime tokio courant. +pub struct Persister { + sh: Arc>, +} + +impl Clone for Persister { + fn clone(&self) -> Self { + Persister { + sh: self.sh.clone(), + } + } +} + +impl Persister { + pub fn new(sink: S) -> Self { + Persister { + sh: Arc::new(Shared { + sink, + st: Arc::new(Mutex::new(PState::default())), + done: watch::Sender::new(0), + seq: AtomicU64::new(0), + }), + } + } + + pub fn sink(&self) -> &S { + &self.sh.sink + } + + /// Numéro du prochain instantané (`persistSeq`). À prendre sous le verrou + /// de l'état, pour que l'ordre des numéros soit celui des états. + pub fn next_seq(&self) -> u64 { + self.sh.seq.fetch_add(1, Ordering::SeqCst) + 1 + } + + /// Confie un instantané et rend son ticket. Un instantané en attente pour + /// la même discussion est remplacé par le plus récent. + pub fn enqueue_snap(&self, s: ConvSnap) -> u64 { + let mut st = lock(&self.sh.st); + if !st.deleted.contains(&s.id) { + let newer = st.snaps.get(&s.id).is_none_or(|cur| s.seq > cur.seq); + if newer { + st.snaps.insert(s.id.clone(), s); + } + } + self.kick(&mut st) + } + + /// Confie un résultat d'outil ; il est servi depuis la mémoire tant qu'il + /// n'est pas sur disque. + pub fn enqueue_tool_res(&self, j: ToolResJob) -> u64 { + let mut st = lock(&self.sh.st); + if !st.deleted.contains(tool_res_conv(&j.key)) { + st.mem.insert(j.key.clone(), j.plain.clone()); + st.res.push(j); + } + self.kick(&mut st) + } + + fn kick(&self, st: &mut PState) -> u64 { + st.queued += 1; + if !st.running { + st.running = true; + tokio::spawn(run(self.sh.clone())); + } + st.queued + } + + /// Attend que le ticket (et tous ceux d'avant) soit traité. + pub async fn wait(&self, ticket: u64) { + let mut rx = self.sh.done.subscribe(); + let _ = rx.wait_for(|d| *d >= ticket).await; + } + + /// Attend que tout ce qui a été confié jusqu'ici soit traité. + pub async fn flush(&self) { + let t = lock(&self.sh.st).queued; + self.wait(t).await; + } + + /// `flushPersister` : [`Self::flush`] borné, pour ne jamais retenir un + /// arrêt sur une base bloquée. Faux si le délai a expiré. + pub async fn flush_timeout(&self, d: Duration) -> bool { + tokio::time::timeout(d, self.flush()).await.is_ok() + } + + /// Prend l'instantané et ATTEND son écriture (`persist`) : fin de tour, + /// reset, bascules. Tout ce qui était en attente avant est écrit aussi. + pub async fn persist(&self, s: ConvSnap) { + let t = self.enqueue_snap(s); + self.wait(t).await; + } + + /// Marque une discussion supprimée : ses instantanés et résultats en + /// attente sont jetés, et plus rien ne la réécrira. L'appelant attend + /// ensuite l'écriture en vol ([`Self::flush`]) avant d'effacer en base. + pub fn forget(&self, id: &str) { + let mut st = lock(&self.sh.st); + st.deleted.insert(id.to_string()); + st.snaps.remove(id); + drop_tool_res_locked(&mut st, &format!("{id}.")); + } + + /// Oublie les résultats en attente dont la clé commence par `prefix` + /// (`dropToolRes`). Ceux du lot en vol sont annulés : l'écrivain les + /// saute, ou les a déjà écrits — l'appelant fait alors [`Self::flush`] + /// avant d'effacer, et rien ne renaît. + pub fn drop_tool_res(&self, prefix: &str) { + drop_tool_res_locked(&mut lock(&self.sh.st), prefix); + } + + /// Relit un résultat pas encore sur disque (`toolResPending`). + pub fn tool_res_pending(&self, key: &str) -> Option> { + lock(&self.sh.st).mem.get(key).cloned() + } +} + +fn drop_tool_res_locked(st: &mut PState, prefix: &str) { + let hit: Vec = st + .inflight + .iter() + .filter(|k| k.starts_with(prefix)) + .cloned() + .collect(); + st.cancelled.extend(hit); + st.mem.retain(|k, _| !k.starts_with(prefix)); + st.res.retain(|j| !j.key.starts_with(prefix)); +} + +/// Vide la file par lots, jusqu'à ce qu'elle soit vide. +async fn run(sh: Arc>) { + loop { + let (upto, mut snaps, res) = { + let mut st = lock(&sh.st); + if st.snaps.is_empty() && st.res.is_empty() { + st.running = false; + st.done = st.queued; + sh.done.send_replace(st.done); + return; + } + let pending = std::mem::take(&mut st.snaps); + let snaps: Vec = pending + .into_values() + .filter(|s| { + !st.deleted.contains(&s.id) + && s.seq > st.written.get(&s.id).copied().unwrap_or(0) + }) + .collect(); + let queued = std::mem::take(&mut st.res); + let mut res = Vec::with_capacity(queued.len()); + for j in queued { + if !st.deleted.contains(tool_res_conv(&j.key)) { + st.inflight.insert(j.key.clone()); + res.push(j); + } + } + (st.queued, snaps, res) + }; + + snaps.sort_by_key(|s| s.seq); + let n_snaps = snaps.len(); + let jobs: Vec<(String, Arc)> = res + .iter() + .map(|j| (j.key.clone(), j.plain.clone())) + .collect(); + let gate = ResGate(sh.st.clone()); + // Un écrivain qui panique ne doit pas laisser la file bloquée : les + // attentes (fin de tour, reset) ne reviendraient jamais. + let out = AssertUnwindSafe(sh.sink.commit(snaps, res, gate)) + .catch_unwind() + .await + .unwrap_or_else(|_| CommitOutcome { + wrote: Vec::new(), + err: Some("écrivain en panique".into()), + }); + + let mut st = lock(&sh.st); + for (key, _) in &jobs { + st.inflight.remove(key); + st.cancelled.remove(key); + } + for (id, seq) in &out.wrote { + let w = st.written.entry(id.clone()).or_insert(0); + if *seq > *w { + *w = *seq; + } + } + match &out.err { + Some(e) => { + // Les résultats restent servis depuis la mémoire ; un + // instantané raté sera remplacé par le suivant. + eprintln!( + "[persist] écriture différée échouée ({} instantané(s), {} résultat(s) d'outil gardé(s) en mémoire) : {e}", + n_snaps.saturating_sub(out.wrote.len()), + jobs.len() + ); + } + None => { + for (key, plain) in &jobs { + if st.mem.get(key).is_some_and(|m| Arc::ptr_eq(m, plain)) { + st.mem.remove(key); + } + } + } + } + st.done = upto; + sh.done.send_replace(upto); + } +} + +// ---- écriture par l'API interne du Go -------------------------------------- + +/// Écrit les lots par l'API interne du Go : scellés par lui (la clé de la +/// mémoire ne sort jamais), rangés par lui dans bbolt. +#[derive(Clone)] +pub struct StoreSink { + pub store: Store, + /// Sérialise les lectures-modifications-écritures de l'index + /// (`convIndexMu`) : celles de l'écrivain et celles des opérations sur + /// les discussions ([`super::sessions`]), qui partagent ce verrou. + pub index_lock: Arc>, +} + +impl StoreSink { + pub fn new(store: Store) -> Self { + StoreSink { + store, + index_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } +} + +impl PersistSink for StoreSink { + async fn commit( + &self, + snaps: Vec, + res: Vec, + gate: ResGate, + ) -> CommitOutcome { + let _index = self.index_lock.lock().await; + commit_batch(&self.store, snaps, res, gate, now_unix()).await + } +} + +/// `commitPersistBatch` à travers l'API interne. Mémoire verrouillée : rien +/// n'est écrit en clair — les instantanés sont sautés (le fil reste en RAM), +/// les résultats d'outils rendent une erreur (ils restent servis depuis la +/// mémoire). Index illisible (autre clé, abîmé) : les contenus sont écrits, +/// l'index n'est PAS touché — le réécrire depuis une lecture vide effacerait +/// les autres discussions de la liste. +pub async fn commit_batch( + store: &Store, + snaps: Vec, + res: Vec, + gate: ResGate, + now: i64, +) -> CommitOutcome { + let mut out = CommitOutcome::default(); + for j in res { + let sealed = match store.seal(j.plain.as_bytes().to_vec()).await { + Ok(b) => b, + Err(e) => { + out.err = Some(crypt_msg(e)); + return out; + } + }; + if !gate.wanted(&j.key) { + continue; // supprimé avec sa discussion pendant que le lot partait + } + if let Err(e) = store.put(BK_TOOL_RES, &j.key, sealed).await { + out.err = Some(e); + return out; + } + } + if snaps.is_empty() { + return out; + } + let mut idx: Option> = match store.get(BK_CHAT, CK_INDEX).await { + Ok(None) => Some(Vec::new()), + Ok(Some(raw)) if raw.is_empty() => Some(Vec::new()), + Ok(Some(raw)) => match store.open(raw).await { + Ok(dec) => serde_json::from_slice::>>(&dec) + .ok() + .map(Option::unwrap_or_default), + Err(CryptError::Locked) => return out, + Err(CryptError::Other(_)) => None, + }, + Err(e) => { + out.err = Some(e); + return out; + } + }; + for s in &snaps { + let sealed = match store.seal(s.body.clone()).await { + Ok(b) => b, + Err(CryptError::Locked) => return out, + Err(e) => { + out.err = Some(crypt_msg(e)); + return out; + } + }; + if let Err(e) = store.put(BK_CHAT, &conv_key(&s.id), sealed).await { + out.err = Some(e); + return out; + } + if let Some(idx) = idx.as_mut() { + touch_meta_in(idx, s, now); + } + out.wrote.push((s.id.clone(), s.seq)); + } + let Some(idx) = idx else { + return out; + }; + match store.seal(encode_index(&idx)).await { + Ok(sealed) => { + if let Err(e) = store.put(BK_CHAT, CK_INDEX, sealed).await { + out.err = Some(e); + } + } + Err(CryptError::Locked) => {} + Err(e) => out.err = Some(crypt_msg(e)), + } + out +} + +fn crypt_msg(e: CryptError) -> String { + match e { + CryptError::Locked => "mémoire verrouillée".into(), + CryptError::Other(s) => s, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::Mutex as StdMutex; + use tokio::sync::{Semaphore, mpsc}; + + fn um(s: &str) -> Message { + Message { + role: "user".into(), + content: Some(json!(s)), + ..Default::default() + } + } + + fn ev(seq: i64, delta: Value) -> LogEvent { + let Value::Object(d) = delta else { + panic!("delta objet attendu") + }; + LogEvent { + seq, + ts: 0, + delta: d, + } + } + + // ---- format ---- + + #[test] + fn fil_vide_ecrit_comme_le_go() { + assert_eq!( + String::from_utf8(ConvState::default().to_json()).unwrap(), + r#"{"messages":null,"log":null,"seq":0,"ctx_used":0}"# + ); + } + + #[test] + fn etat_aller_retour() { + let st = ConvState { + messages: vec![um("bonjour")], + log: vec![ev(1, json!({"user": "bonjour"}))], + seq: 1, + ctx_used: 42, + proj_snap: Some(ProjSnapshot { + at: 7, + key: "k".into(), + state: ProjState { + mem: BTreeMap::from([("b".into(), "2".into()), ("a".into(), "1".into())]), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + let b = st.to_json(); + // Les compteurs en mémoire ne s'écrivent pas. + let mem = ConvState { + ctx_used_len: 3, + gen_peak: 900, + ..st.clone() + }; + assert_eq!(mem.to_json(), b); + assert_eq!( + String::from_utf8(b.clone()).unwrap(), + r#"{"messages":[{"role":"user","content":"bonjour"}],"log":[{"seq":1,"ts":0,"delta":{"user":"bonjour"}}],"seq":1,"ctx_used":42,"proj_snap":{"at":7,"state":{"mem":{"a":"1","b":"2"}},"key":"k","sys_hash":""}}"# + ); + assert_eq!(decode_state(&b), st); + } + + #[test] + fn relecture_tolerante() { + assert_eq!(decode_state(b""), ConvState::default()); + assert_eq!(decode_state(b"pas du json"), ConvState::default()); + assert_eq!(decode_state(b"[1,2]"), ConvState::default()); + // Un champ mal typé est ignoré seul ; null vaut vide. + let st = decode_state( + br#"{"messages":[{"role":"user","content":"a"},{"role":5}],"log":null,"seq":"x","ctx_used":12,"inconnu":1}"#, + ); + assert_eq!(st.messages, vec![um("a")]); + assert!(st.log.is_empty()); + assert_eq!(st.seq, 0); + assert_eq!(st.ctx_used, 12); + assert_eq!(st.proj_snap, None); + } + + #[test] + fn guerison_des_images_persistees() { + let b = json!({"messages": [ + {"role": "user", "content": "bonjour"}, + {"role": "user", "content": [ + {"type": "text", "text": "Voici la capture demandée."}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AAAA"}} + ]}, + {"role": "user", "content": [{"type": "text", "text": "sans image"}]} + ]}); + let st = decode_state(b.to_string().as_bytes()); + assert_eq!(st.messages[0].content, Some(json!("bonjour"))); + assert_eq!( + st.messages[1].content, + Some(json!(format!( + "Voici la capture demandée.{IMAGE_LOST_MARKER}" + ))) + ); + assert_eq!( + st.messages[2].content, + Some(json!([{"type": "text", "text": "sans image"}])) + ); + } + + #[test] + fn instantane_en_plein_tour_ecrit_le_journal_compacte() { + let st = ConvState { + messages: vec![um("titre de la discussion")], + log: vec![ + ev(1, json!({"user": "titre de la discussion"})), + ev(2, json!({"content": "Bon"})), + ev(3, json!({"content": "jour"})), + ], + seq: 3, + ..Default::default() + }; + let s = take_snapshot(&st, true, "c1", 9, "generale"); + assert_eq!((s.id.as_str(), s.seq, s.turns), ("c1", 9, 1)); + assert_eq!(s.title, "titre de la discussion"); + assert_eq!(s.project, "generale"); + let back = decode_state(&s.body); + assert_eq!(back.log, compact_log(&st.log)); + assert_eq!(back.log.len(), 2); + // Le journal en mémoire n'a pas bougé ; hors tour, il est écrit tel quel. + assert_eq!(st.log.len(), 3); + let s = take_snapshot(&st, false, "c1", 10, ""); + assert_eq!(decode_state(&s.body).log, st.log); + } + + // ---- index ---- + + #[test] + fn titre_deduit() { + assert_eq!(conv_summary(&[]), ""); + let mut nudge = um(super::super::compact::THINK_NUDGE_FIRST); + assert_eq!(conv_summary(&[nudge.clone(), um("bonjour")]), "bonjour"); + nudge.img_relay = true; + assert_eq!(conv_summary(&[nudge, um(" a\nb ")]), "a b"); + let assistant = Message { + role: "assistant".into(), + content: Some(json!("réponse")), + ..Default::default() + }; + let multi = Message { + role: "user".into(), + content: Some(json!([{"type": "text", "text": "image"}])), + ..Default::default() + }; + assert_eq!(conv_summary(&[assistant, multi, um(" "), um("ok")]), "ok"); + // 60 caractères, découpés sur les caractères et non les octets. + let long = "é".repeat(59) + " suite"; + assert_eq!(conv_summary(&[um(&long)]), "é".repeat(59) + "…"); + } + + #[test] + fn titre_sans_bloc_projet() { + let mut m = um(&format!( + "{}\n+ x\n{}\n\ncorrige le build", + super::super::compact::CTX_UPD_OPEN, + super::super::compact::CTX_UPD_CLOSE + )); + m.ctx_upd = true; + assert_eq!(conv_summary(&[m]), "corrige le build"); + } + + #[test] + fn entree_d_index_rafraichie_ou_ajoutee() { + let snap = ConvSnap { + id: "c1".into(), + seq: 1, + body: vec![], + title: "déduit".into(), + turns: 3, + project: "p".into(), + }; + let mut idx = vec![ConvMeta { + id: "c1".into(), + title: "choisi".into(), + created: 1, + updated: 1, + turns: 1, + project: String::new(), + }]; + touch_meta_in(&mut idx, &snap, 50); + assert_eq!( + idx[0], + ConvMeta { + id: "c1".into(), + title: "choisi".into(), + created: 1, + updated: 50, + turns: 3, + project: String::new() + } + ); + idx[0].title.clear(); + touch_meta_in(&mut idx, &snap, 60); + assert_eq!(idx[0].title, "déduit"); + let other = ConvSnap { + id: "c2".into(), + ..snap + }; + touch_meta_in(&mut idx, &other, 70); + assert_eq!( + idx[1], + ConvMeta { + id: "c2".into(), + title: "déduit".into(), + created: 70, + updated: 70, + turns: 3, + project: "p".into() + } + ); + } + + #[test] + fn index_tri_projets_et_operations() { + let raw = br#"[{"id":"a","title":"","created":1,"updated":5,"turns":0}, + {"id":"b","title":"B","created":1,"updated":9,"turns":2,"project":"x"}, + {"id":"c","title":"","created":1,"updated":5,"turns":0,"project":" "}]"#; + let mut idx = decode_index(raw); + let ids: Vec<&str> = idx.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["b", "a", "c"], "plus récente en tête, tri stable"); + assert_eq!(decode_index(b"null"), vec![]); + assert_eq!(decode_index(b"{"), vec![]); + + assert_eq!(conv_project_of(&idx[1]), DEFAULT_PROJECT_SLUG); + let generale: Vec<&str> = index_for_project(&idx, DEFAULT_PROJECT_SLUG) + .iter() + .map(|m| m.id.as_str()) + .collect(); + assert_eq!(generale, ["a", "c"]); + assert!(tag_orphans(&mut idx, "generale")); + assert!(!tag_orphans(&mut idx, "generale")); + assert_eq!(idx[2].project, "generale"); + + assert_eq!(rename_in(&mut idx, "a", " Nom "), Ok(())); + assert_eq!(idx[1].title, "Nom"); + assert_eq!(rename_in(&mut idx, "a", " "), Err(IndexError::Missing)); + assert_eq!(rename_in(&mut idx, "zz", "t"), Err(IndexError::NotFound)); + assert!(remove_from(&mut idx, "b")); + assert!(!remove_from(&mut idx, "b")); + assert_eq!( + String::from_utf8(encode_index(&idx[..1])).unwrap(), + r#"[{"id":"a","title":"Nom","created":1,"updated":5,"turns":0,"project":"generale"}]"# + ); + assert_eq!(encode_index(&[]), b"null"); + } + + #[test] + fn cles() { + assert_eq!(conv_key("c1"), "conv:c1"); + assert_eq!(tool_res_conv("c123.ab12"), "c123"); + assert_eq!(tool_res_conv("c1.2.x"), "c1.2"); + assert_eq!(tool_res_conv("nosession"), "nosession"); + assert_eq!(tool_res_conv(".x"), ".x"); + let id = new_conv_id(); + assert!( + id.starts_with('c') && id[1..].parse::().is_ok(), + "{id}" + ); + } + + // ---- écrivain ---- + + /// Faux écrivain : enregistre ce qu'il écrit ; retenu au besoin (chaque + /// lot s'annonce sur `entered` puis attend un jeton de `release`). + #[derive(Default)] + struct FakeSink { + gate: Option<(mpsc::UnboundedSender>, Arc)>, + seqs: StdMutex>, + disk: StdMutex>>, + res: StdMutex>, + fail: StdMutex, + delay: Option, + } + + impl PersistSink for Arc { + fn commit( + &self, + snaps: Vec, + res: Vec, + gate: ResGate, + ) -> impl Future + Send { + let me = self.clone(); + async move { + if let Some((entered, release)) = &me.gate { + let _ = entered.send(snaps.iter().map(|s| s.seq).collect()); + release.acquire().await.unwrap().forget(); + } + if let Some(d) = me.delay { + tokio::time::sleep(d).await; + } + if *me.fail.lock().unwrap() { + return CommitOutcome { + wrote: vec![], + err: Some("disque plein".into()), + }; + } + for j in res { + if gate.wanted(&j.key) { + me.res.lock().unwrap().insert(j.key, j.plain.to_string()); + } + } + let mut out = CommitOutcome::default(); + for s in snaps { + me.seqs.lock().unwrap().push(s.seq); + me.disk.lock().unwrap().insert(s.id.clone(), s.body); + out.wrote.push((s.id, s.seq)); + } + out + } + } + } + + fn gated() -> ( + Arc, + mpsc::UnboundedReceiver>, + Arc, + ) { + let (tx, rx) = mpsc::unbounded_channel(); + let sem = Arc::new(Semaphore::new(0)); + let sink = Arc::new(FakeSink { + gate: Some((tx, sem.clone())), + ..Default::default() + }); + (sink, rx, sem) + } + + async fn entered(rx: &mut mpsc::UnboundedReceiver>) -> Vec { + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("aucun lot n'a atteint l'écriture") + .unwrap() + } + + fn snap(id: &str, seq: u64, body: &str) -> ConvSnap { + ConvSnap { + id: id.into(), + seq, + body: body.as_bytes().to_vec(), + title: String::new(), + turns: 0, + project: String::new(), + } + } + + fn job(key: &str, plain: &str) -> ToolResJob { + ToolResJob { + key: key.into(), + plain: Arc::from(plain), + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn jamais_un_etat_plus_ancien() { + let sink = Arc::new(FakeSink { + delay: Some(Duration::from_millis(2)), + ..Default::default() + }); + let p = Persister::new(sink.clone()); + // L'état et son numéro sont pris sous le même verrou, comme sous c.mu. + let state = Arc::new(StdMutex::new(0u32)); + let mut tasks = Vec::new(); + for _ in 0..8 { + let (p, state) = (p.clone(), state.clone()); + tasks.push(tokio::spawn(async move { + for _ in 0..10 { + let s = { + let mut n = state.lock().unwrap(); + *n += 1; + snap("c1", p.next_seq(), &n.to_string()) + }; + p.enqueue_snap(s); + tokio::task::yield_now().await; + } + })); + } + for t in tasks { + t.await.unwrap(); + } + p.flush().await; + let seqs = sink.seqs.lock().unwrap().clone(); + assert!( + seqs.windows(2).all(|w| w[1] > w[0]), + "un état plus ancien a écrasé un plus récent : {seqs:?}" + ); + assert_eq!(sink.disk.lock().unwrap()["c1"], b"80"); + } + + #[tokio::test] + async fn persist_attend_l_ecriture_en_vol() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + p.enqueue_snap(snap("c1", 1, "avant")); + assert_eq!(entered(&mut rx).await, vec![1]); + let p2 = p.clone(); + let done = tokio::spawn(async move { p2.persist(snap("c1", 2, "vide")).await }); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !done.is_finished(), + "revenu avant la fin de l'écriture en vol" + ); + release.add_permits(100); + tokio::time::timeout(Duration::from_secs(5), done) + .await + .expect("persist bloqué") + .unwrap(); + assert_eq!(sink.disk.lock().unwrap()["c1"], b"vide"); + } + + #[tokio::test] + async fn instantane_en_attente_remplace_et_ancien_ignore() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + p.enqueue_snap(snap("c1", 1, "un")); + entered(&mut rx).await; + p.enqueue_snap(snap("c1", 3, "trois")); + p.enqueue_snap(snap("c1", 2, "deux")); // plus ancien que l'attente : écarté + release.add_permits(100); + assert_eq!(entered(&mut rx).await, vec![3]); + p.flush().await; + // Déjà écrit plus récent : un instantané plus ancien ne part pas. + p.enqueue_snap(snap("c1", 2, "deux")); + p.flush().await; + assert_eq!(*sink.seqs.lock().unwrap(), vec![1, 3]); + assert_eq!(sink.disk.lock().unwrap()["c1"], b"trois"); + } + + #[tokio::test] + async fn resultat_servi_avant_l_ecriture() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + let long = "sortie\n".repeat(1000); + p.enqueue_tool_res(job("c1.a", &long)); + entered(&mut rx).await; + assert_eq!(p.tool_res_pending("c1.a").as_deref(), Some(long.as_str())); + release.add_permits(100); + p.flush().await; + assert!( + p.tool_res_pending("c1.a").is_none(), + "copie en mémoire restée" + ); + assert_eq!(sink.res.lock().unwrap()["c1.a"], long); + } + + #[tokio::test] + async fn resultat_supprime_avant_puis_pendant_l_ecriture() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + p.enqueue_tool_res(job("c1.a", "a")); // retient l'écrivain + entered(&mut rx).await; + p.enqueue_tool_res(job("c2.b", "b")); // en attente derrière + p.drop_tool_res("c2."); + p.drop_tool_res("c1."); // déjà pris par le lot en vol : annulé + assert!(p.tool_res_pending("c1.a").is_none()); + release.add_permits(100); + p.flush().await; + assert!( + sink.res.lock().unwrap().is_empty(), + "un résultat supprimé a été écrit" + ); + // L'annulation ne vaut que pour ce lot. + p.enqueue_tool_res(job("c1.c", "c")); + p.flush().await; + assert!(sink.res.lock().unwrap().contains_key("c1.c")); + } + + #[tokio::test] + async fn suppression_ne_ressuscite_rien() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + p.enqueue_snap(snap("b", 1, "b")); + entered(&mut rx).await; // un lot est en vol + p.enqueue_snap(snap("a", 2, "a")); + p.enqueue_tool_res(job("a.x", "x")); + p.forget("a"); + p.enqueue_snap(snap("a", 3, "a encore")); // après la suppression : ignoré + p.enqueue_tool_res(job("a.y", "y")); + release.add_permits(100); + p.flush().await; + assert!(!sink.disk.lock().unwrap().contains_key("a")); + assert!(sink.res.lock().unwrap().is_empty()); + assert!(p.tool_res_pending("a.y").is_none()); + assert_eq!(sink.disk.lock().unwrap()["b"], b"b"); + } + + #[tokio::test] + async fn echec_d_ecriture_garde_les_resultats_en_memoire() { + let sink = Arc::new(FakeSink::default()); + *sink.fail.lock().unwrap() = true; + let p = Persister::new(sink.clone()); + p.enqueue_snap(snap("c1", 1, "un")); + p.enqueue_tool_res(job("c1.a", "a")); + p.flush().await; + assert_eq!(p.tool_res_pending("c1.a").as_deref(), Some("a")); + // Le même instantané repart au suivant (rien n'a été marqué écrit). + *sink.fail.lock().unwrap() = false; + p.enqueue_snap(snap("c1", 1, "un")); + p.flush().await; + assert_eq!(sink.disk.lock().unwrap()["c1"], b"un"); + } + + #[tokio::test] + async fn ecrivain_en_panique_ne_bloque_pas_la_file() { + struct Panique; + impl PersistSink for Panique { + async fn commit( + &self, + _: Vec, + _: Vec, + _: ResGate, + ) -> CommitOutcome { + panic!("boum") + } + } + let p = Persister::new(Panique); + p.enqueue_snap(snap("c1", 1, "un")); + assert!( + p.flush_timeout(Duration::from_secs(5)).await, + "file bloquée" + ); + } +} diff --git a/rust/loki-rs/src/chat/sessions.rs b/rust/loki-rs/src/chat/sessions.rs new file mode 100644 index 0000000..d45832b --- /dev/null +++ b/rust/loki-rs/src/chat/sessions.rs @@ -0,0 +1,483 @@ +//! Plusieurs discussions — port des opérations de `chat_sessions.go` et du +//! chargement au démarrage (`LoadConversation`, `chat_conversation.go`). +//! +//! Une seule discussion en mémoire ([`Conversation`]), rangée dans un tiroir +//! nommé (`chat/conv:`), listée dans `chat/index`, ouverte selon +//! `chat/active` — partagée par tous les appareils. Basculer = enregistrer la +//! courante, charger l'autre et incrémenter l'epoch : les abonnés reçoivent +//! `{reset}` et rejouent le nouveau fil. +//! +//! Les opérations de haut niveau (création, bascule, renommage, suppression) +//! sont sérialisées (`convOpMu`) ; les lectures-modifications-écritures de +//! l'index partagent leur verrou avec l'écrivain ([`StoreSink::index_lock`], +//! `convIndexMu`). +//! +//! Ce qui vit hors de la conversation reste derrière [`SessionHooks`] : les +//! fichiers, travaux, critères et mode d'une discussion supprimée, ses +//! résultats d'outils, le préchauffage après une bascule, et la discussion +//! active vue par les outils du Go ([`GoSessionHooks`]). Le changement de +//! projet ([`Sessions::project_switch`]) se fait ici : la discussion du +//! projet quitté est enregistrée avant que le Go ne change le projet actif +//! ([`SessionHooks::project`]) ; les discussions d'un projet supprimé +//! partent par ici aussi ([`Sessions::drop_project`]). + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use super::conv::Conversation; +use super::persist::{ + BK_CHAT, CK_ACTIVE, CK_INDEX, CK_LEGACY, ConvMeta, DEFAULT_PROJECT_SLUG, IndexError, Persister, + StoreSink, conv_key, decode_index, encode_index, index_for_project, new_conv_id, now_unix, + remove_from, rename_in, +}; +use crate::store::{BK_STATE, Store}; + +/// Projet actif (`stActiveProjectKey`, bucket `state`). +pub const ST_ACTIVE_PROJECT: &str = "active_project"; + +/// Essais de lecture au démarrage, et attente entre deux (`loadConvAttempts`, +/// `loadConvRetryWait`) : l'ancien process peut encore tenir la base. +pub const LOAD_ATTEMPTS: u32 = 4; +pub const LOAD_RETRY_WAIT: Duration = Duration::from_millis(250); + +/// Ce qui, autour d'une discussion, ne vit pas dans la conversation. +pub trait SessionHooks: Send + Sync + 'static { + /// Discussion supprimée : ses fichiers, travaux d'arrière-plan, critères, + /// mode et résultats d'outils partent avec elle. + fn dropped(&self, id: &str) -> impl Future + Send; + /// Bascule faite (`prewarmAfterSwitch`). + fn switched(&self, id: &str) -> impl Future + Send; + /// La discussion ouverte a changé (chargement, création, bascule) : ce + /// qui lit l'identifiant actif ailleurs (outils du Go) doit suivre. + fn activated(&self, _id: &str) -> impl Future + Send { + async {} + } + /// `setActiveProject` : le projet actif change (le Go le range et tient + /// le cache de ceux qui le lisent : mémoire, trackers, outils). + fn project(&self, _slug: &str) -> impl Future> + Send { + async { Ok(()) } + } +} + +/// Les effets réels, par l'API interne du Go. +pub struct GoSessionHooks(pub Store); + +impl SessionHooks for GoSessionHooks { + async fn dropped(&self, id: &str) { + let body = serde_json::json!({ "id": id }); + if let Err(e) = self + .0 + .internal_json( + hyper::Method::POST, + "/internal/v1/conv/dropped", + Some(&body), + ) + .await + { + eprintln!("[conv] nettoyage de la discussion {id} non fait : {e}"); + } + } + + async fn switched(&self, _id: &str) {} + + async fn project(&self, slug: &str) -> Result<(), String> { + let (status, v) = self + .0 + .internal_post( + "/internal/v1/project/activate", + &serde_json::json!({ "slug": slug }), + ) + .await?; + if status.is_success() { + return Ok(()); + } + Err(v["error"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| format!("projet non activé : {status}"))) + } + + async fn activated(&self, id: &str) { + let body = serde_json::json!({ "id": id }); + if let Err(e) = self + .0 + .internal_json(hyper::Method::POST, "/internal/v1/conv/active", Some(&body)) + .await + { + eprintln!("[conv] discussion active non transmise au Go ({id}) : {e}"); + } + } +} + +/// Refus d'une opération sur les discussions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionError { + /// « identifiant manquant » / « identifiant ou titre manquant » + Missing, + /// « discussion introuvable » + NotFound, + /// Base illisible. + Store(String), +} + +impl std::fmt::Display for SessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SessionError::Missing => f.write_str("identifiant ou titre manquant"), + SessionError::NotFound => f.write_str("discussion introuvable"), + SessionError::Store(e) => write!(f, "base illisible : {e}"), + } + } +} + +impl From for SessionError { + fn from(e: IndexError) -> Self { + match e { + IndexError::Missing => SessionError::Missing, + IndexError::NotFound => SessionError::NotFound, + } + } +} + +/// Les discussions de l'utilisateur, autour de la discussion ouverte. +pub struct Sessions { + store: Store, + conv: Arc, + persister: Persister, + hooks: H, + /// `convOpMu`. + op: tokio::sync::Mutex<()>, +} + +impl Sessions { + pub fn new(store: Store, conv: Arc, hooks: H) -> Self { + let persister = Persister::new(StoreSink::new(store.clone())); + Sessions { + store, + conv, + persister, + hooks, + op: tokio::sync::Mutex::new(()), + } + } + + pub fn conv(&self) -> &Arc { + &self.conv + } + + pub fn persister(&self) -> &Persister { + &self.persister + } + + /// `activeProjectSlug` : projet actif, le projet par défaut à défaut. + pub async fn active_project(&self) -> String { + match self.store.get_str(BK_STATE, ST_ACTIVE_PROJECT).await.trim() { + "" => DEFAULT_PROJECT_SLUG.to_string(), + s => s.to_string(), + } + } + + /// `convIndex` : l'index, plus récente en tête. Illisible ou verrouillé : + /// vide — la liste est masquée, rien n'est perdu. + pub async fn read_index(&self) -> Vec { + match self.store.get_open(BK_CHAT, CK_INDEX).await { + Ok(Some(b)) => decode_index(&b), + _ => Vec::new(), + } + } + + async fn save_index(&self, idx: &[ConvMeta]) { + if let Err(e) = self + .store + .put_seal(BK_CHAT, CK_INDEX, encode_index(idx)) + .await + { + eprintln!("[conv] index non enregistré : {e:?}"); + } + } + + /// Ajoute une entrée à l'index (création, migration). + async fn index_add(&self, id: &str) { + let now = now_unix(); + let project = self.active_project().await; + let _g = self.persister.sink().index_lock.lock().await; + let mut idx = self.read_index().await; + idx.push(ConvMeta { + id: id.to_string(), + title: String::new(), + created: now, + updated: now, + turns: 0, + project, + }); + self.save_index(&idx).await; + } + + /// `LoadConversation` : recharge la discussion active au démarrage. Une + /// lecture RATÉE n'est pas une absence : on réessaie, et en cas d'échec + /// durable on ne touche à rien (ni identifiant forgé, ni fil écrasé). + pub async fn load(&self) -> Result<(), String> { + let _op = self.op.lock().await; + let mut last_err = String::new(); + for attempt in 0..LOAD_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(LOAD_RETRY_WAIT).await; + } + // Sonde : tant qu'elle échoue, surtout ne pas créer de discussion. + if let Err(e) = self.store.get(BK_CHAT, CK_ACTIVE).await { + last_err = e; + continue; + } + let id = self.ensure_active_locked().await?; + match self.store.get_open(BK_CHAT, &conv_key(&id)).await { + Ok(body) => { + self.conv.load_from(&id, &body.unwrap_or_default()); + self.hooks.activated(&id).await; + return Ok(()); + } + Err(e) => last_err = e, + } + } + eprintln!( + "[conv] base illisible au démarrage ({last_err}) — aucune discussion chargée ; rien n'a été écrasé, l'historique est intact" + ); + Err(last_err) + } + + /// `reloadEncryptedStores` : après le déverrouillage de la mémoire, + /// recharge la discussion si elle a été chargée vide (mémoire verrouillée + /// au démarrage). Jamais un fil déjà rempli : écraser le tour en cours le + /// perdrait. Rend vrai si elle a été rechargée. + pub async fn reload_if_empty(&self) -> Result { + let _op = self.op.lock().await; + if self.conv.is_generating() || !self.conv.with_state(|st| st.log.is_empty()) { + return Ok(false); + } + let id = self.ensure_active_locked().await?; + let Some(body) = self.store.get_open(BK_CHAT, &conv_key(&id)).await? else { + return Ok(false); + }; + self.conv.load_from(&id, &body); + self.hooks.activated(&id).await; + Ok(true) + } + + /// `convEnsureActive` : identifiant de la discussion active, créée au + /// besoin (reprise silencieuse du fil unique d'avant les discussions). + pub async fn ensure_active(&self) -> Result { + let _op = self.op.lock().await; + self.ensure_active_locked().await + } + + async fn ensure_active_locked(&self) -> Result { + let cached = self.conv.id(); + if !cached.is_empty() { + return Ok(cached); + } + if let Some(raw) = self.store.get(BK_CHAT, CK_ACTIVE).await? { + let id = String::from_utf8_lossy(&raw).trim().to_string(); + if !id.is_empty() { + return Ok(id); + } + } + let id = new_conv_id(); + if let Some(legacy) = self.store.get(BK_CHAT, CK_LEGACY).await? + && !legacy.is_empty() + { + // Le fil unique devient la première discussion ; la clé d'origine + // reste intacte (retour arrière sans perte). + if let Err(e) = self.store.put_seal(BK_CHAT, &conv_key(&id), legacy).await { + eprintln!("[conv] reprise du fil unique non écrite : {e:?}"); + } + } + self.store + .put(BK_CHAT, CK_ACTIVE, id.as_bytes().to_vec()) + .await?; + self.index_add(&id).await; + Ok(id) + } + + /// `ConvList` : les discussions du projet actif, et l'active. + pub async fn list(&self) -> Result<(Vec, String), String> { + let project = self.active_project().await; + let idx = self.read_index().await; + let list = index_for_project(&idx, &project) + .into_iter() + .cloned() + .collect(); + Ok((list, self.ensure_active().await?)) + } + + /// `convActivate` : pointeur en base, identifiant et contenu changent + /// ensemble. + async fn activate(&self, id: &str) -> Result<(), SessionError> { + self.store + .put(BK_CHAT, CK_ACTIVE, id.as_bytes().to_vec()) + .await + .map_err(SessionError::Store)?; + let body = self + .store + .get_open(BK_CHAT, &conv_key(id)) + .await + .ok() + .flatten() + .unwrap_or_default(); + self.conv.load_from(id, &body); + self.hooks.activated(id).await; + Ok(()) + } + + /// `persist` : enregistre la discussion ouverte et ATTEND l'écriture. + pub async fn persist(&self) { + let project = self.active_project().await; + let snap = self.conv.snapshot(&self.persister, &project); + if snap.id.is_empty() { + self.persister.flush().await; + return; + } + self.persister.persist(snap).await; + } + + /// `persistAsync` : instantané tout de suite, écriture par l'écrivain. + pub async fn persist_async(&self) { + let project = self.active_project().await; + let snap = self.conv.snapshot(&self.persister, &project); + if !snap.id.is_empty() { + self.persister.enqueue_snap(snap); + } + } + + /// `convCreate` : discussion vide ouverte SANS enregistrer la courante. + async fn create_locked(&self) -> Result { + let id = new_conv_id(); + self.index_add(&id).await; + self.activate(&id).await?; + Ok(id) + } + + /// `convNew` : met la courante de côté puis en ouvre une neuve. + pub async fn new_conv(&self) -> Result { + let _op = self.op.lock().await; + self.persist().await; + self.create_locked().await + } + + /// `convSwitch` : enregistre la courante, charge la cible. + pub async fn switch(&self, id: &str) -> Result<(), SessionError> { + let _op = self.op.lock().await; + if id.is_empty() { + return Err(SessionError::Missing); + } + if !self.read_index().await.iter().any(|m| m.id == id) { + return Err(SessionError::NotFound); + } + if id + == self + .ensure_active_locked() + .await + .map_err(SessionError::Store)? + { + return Ok(()); + } + self.persist().await; + self.activate(id).await?; + self.hooks.switched(id).await; + Ok(()) + } + + /// `projectSwitch` : la discussion du projet quitté est enregistrée (avec + /// son projet), le projet actif change, puis la plus récente du nouveau + /// projet s'ouvre — une neuve s'il n'en a pas. L'appelant vérifie + /// qu'aucun tour ne tourne. + pub async fn project_switch(&self, slug: &str) -> Result<(), SessionError> { + let slug = slug.trim(); + if slug.is_empty() { + return Err(SessionError::Missing); + } + let _op = self.op.lock().await; + if slug == self.active_project().await { + return Ok(()); + } + self.persist().await; // fige la discussion du projet qu'on quitte + self.hooks + .project(slug) + .await + .map_err(SessionError::Store)?; + let idx = self.read_index().await; + match index_for_project(&idx, slug).first() { + None => self.create_locked().await.map(|_| ()), + Some(m) => { + let id = m.id.clone(); + self.activate(&id).await?; + self.hooks.switched(&id).await; + Ok(()) + } + } + } + + /// Les discussions d'un projet supprimé (`deleteProject`), une à une + /// comme [`Self::delete`] : supprimer l'ouverte bascule sur la plus + /// récente restante. Rend leur nombre. + pub async fn drop_project(&self, slug: &str) -> Result { + let ids: Vec = index_for_project(&self.read_index().await, slug.trim()) + .into_iter() + .map(|m| m.id.clone()) + .collect(); + for id in &ids { + match self.delete(id).await { + Ok(()) | Err(SessionError::NotFound) => {} + Err(e) => return Err(e), + } + } + Ok(ids.len()) + } + + /// `convRename`. + pub async fn rename(&self, id: &str, title: &str) -> Result<(), SessionError> { + let _op = self.op.lock().await; + let _g = self.persister.sink().index_lock.lock().await; + let mut idx = self.read_index().await; + rename_in(&mut idx, id, title)?; + self.save_index(&idx).await; + Ok(()) + } + + /// `convDelete` : supprimer celle qui est ouverte bascule sur la plus + /// récente restante, ou sur une neuve : il y a TOUJOURS une discussion + /// active. + pub async fn delete(&self, id: &str) -> Result<(), SessionError> { + let _op = self.op.lock().await; + if !self.read_index().await.iter().any(|m| m.id == id) { + return Err(SessionError::NotFound); + } + // Rien ne doit plus la viser, puis l'écriture en vol est attendue : + // sinon elle la ferait renaître, contenu et entrée d'index. + self.persister.forget(id); + self.persister.flush().await; + let next = { + let _g = self.persister.sink().index_lock.lock().await; + let mut idx = self.read_index().await; + remove_from(&mut idx, id); + self.save_index(&idx).await; + idx + }; + if let Err(e) = self.store.del(BK_CHAT, &conv_key(id)).await { + eprintln!("[conv] contenu de {id} non effacé : {e}"); + } + self.hooks.dropped(id).await; + if id + != self + .ensure_active_locked() + .await + .map_err(SessionError::Store)? + { + return Ok(()); + } + match next.first() { + // Surtout pas new_conv : il réenregistrerait la supprimée. + None => self.create_locked().await.map(|_| ()), + Some(m) => self.activate(&m.id.clone()).await, + } + } +} diff --git a/rust/loki-rs/src/chat/turn.rs b/rust/loki-rs/src/chat/turn.rs new file mode 100644 index 0000000..7eb782f --- /dev/null +++ b/rust/loki-rs/src/chat/turn.rs @@ -0,0 +1,931 @@ +//! La boucle d'un tour — port de `runChatLoop` (`llm_client.go`). +//! +//! Un tour = des complétions enchaînées jusqu'à une réponse : envoi de la +//! requête, lecture du flux ([`StreamParser`]), exécution des appels d'outils +//! puis nouvelle complétion, avec les filets du Go : +//! - reprises réseau AVANT toute réponse (connexion refusée, délai, coupure ; +//! statuts 429/502/503/504), et flux coupé en cours de réponse vers une API +//! externe (rejoué, ou poursuivi après le texte déjà affiché) ; +//! - refus du moteur : intensité de raisonnement refusée par le gabarit, +//! raisonnement renvoyé refusé ou trop long, débordement de contexte +//! (compaction réactive, puis réduction forcée), 500 sur un appel mal formé +//! (`tool_choice: none` puis outils retirés) ; +//! - réponse vide : compaction et rejeu si la fenêtre est pleine, sinon +//! relances « pensé sans agir » ; appel d'outil écrit en texte : relance +//! corrective ; budget souple d'appels d'outils ; appels identiques +//! court-circuités ; arguments illisibles jamais exécutés ; +//! - compaction en cours de tour quand la boucle d'outils remplit la fenêtre ; +//! - messages mis en file par l'utilisateur injectés entre deux étapes. +//! +//! La boucle ne fait AUCUNE entrée-sortie elle-même : tout passe par +//! [`TurnHost`] (envoi au moteur, outils, compaction, résultats complets, +//! file d'attente, mémoires des refus). Les événements partent dans le rappel +//! `cb` (comme le `ChatCallback` Go) ; le tour rend ses messages à ajouter à +//! 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 +//! de ces clés, le Go garde la conversation (`facadeChatBlocker`, décidé au +//! démarrage). + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Instant; + +use bytes::Bytes; +use futures_util::{Stream, StreamExt}; +use serde_json::{Map, Value}; +use tokio::sync::watch; + +use super::compact::cap_tool_result; +use super::compact::{ + CompactConfig, compact_needed, compact_would_trigger, context_overflow, estimate_tokens, + log_compact, log_ctx, log_ctx_estimate, overflow_tokens, shrink_to_fit, +}; +use super::engine::{ + LineError, Lines, NET_RETRIES, NetKind, net_delay, retryable, retryable_status, + stream_cut_error, +}; +use super::nudges::{ + MAX_NUDGES, THINK_NUDGE_FIRST, THINK_NUDGE_STUCK, append_nudge, budget_nudge, dedupable_tool, + log_budget, repeated_call_result, steer_system, tool_result_preview, +}; +use super::stream::{ + CacheHintFn, MAX_PATTERN_RETRIES, PreflightFn, RetryPatterns, StreamConfig, StreamParser, + call_args, retry_corrective, tool_call_label, +}; +use super::types::{ + AskEvent, DiffLine, Message, ReasoningEcho, StreamEvent, Tool, ToolCall, ToolCallFunc, + ToolUsedEvent, +}; +use super::wire::{ + ChatEndpoint, ChatPayloadOpts, EchoPolicy, TOOLS_OFF_HINT, TurnReasoning, build_chat_payload, + echo_template_error, effort_from_rejection, log_effort_fallback, reasoning_active, + reasoning_echo_model, reasoning_template_kwargs, strip_reasoning, turn_echo_policy, + wire_messages, with_echo, +}; + +/// Reprises d'un flux coupé en cours de réponse (`maxStreamRetries`). +pub const MAX_STREAM_RETRIES: u32 = 5; +/// Corps d'un refus du moteur lu au plus (octets). +const ERROR_BODY_MAX: usize = 2000; +/// Consigne qui suit le texte déjà affiché quand un flux externe est coupé. +pub const STREAM_CUT_CONTINUE: &str = "The connection was cut during your answer. Continue exactly where you stopped, without repeating what you already wrote."; + +/// Corps d'une réponse du moteur. +pub type BodyStream = Pin> + Send>>; + +/// Réponse du moteur (statut et corps). +pub struct Reply { + pub status: u16, + pub body: BodyStream, +} + +/// Échec d'envoi (rien n'est encore parti à l'écran). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SendError { + pub kind: NetKind, + /// Message pour l'utilisateur (`friendlyLLMError`). + pub message: String, +} + +/// Ce qu'a produit un appel d'outil (`toolExecResult` du Go). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ToolOutcome { + pub result: String, + pub diff: Vec, + pub added: i64, + pub removed: i64, + pub ask: Option, + /// Capture à afficher dans la bulle (web_screenshot). + pub image: String, + /// Aperçu en direct du navigateur piloté. + pub live_shot: String, + /// Image relayée au modèle (capture, see_image) : ÉPHÉMÈRE, elle entre dans + /// la vue du tour mais jamais dans l'historique. + pub relay: Option, +} + +/// Où en est le tour quand il demande une compaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompactPhase { + /// Le moteur a refusé un prompt trop long. + Reactive, + /// Entre deux étapes : `used` = contexte réel estimé ; `rescue` : relance + /// de secours (outils coupés ou neutralisés). + Mid { used: i64, rescue: bool }, + /// Complétion coupée par la fenêtre pleine (finish_reason « length »). + FullWindow, +} + +/// Les effets d'un tour, fournis par l'appelant. +pub trait TurnHost: Send + Sync { + /// POST du corps vers l'endpoint (sa clé à lui). + fn send( + &self, + ep: &ChatEndpoint, + body: Vec, + ) -> impl Future> + Send; + /// Exécute un appel d'outil (`execTool`). + fn exec_tool( + &self, + name: &str, + args: &Map, + label: &str, + ) -> impl Future + Send; + /// Range un résultat complet (« voir plus ») et rend son id ("" : échec). + fn save_tool_result(&self, result: &str) -> String; + /// Compacte l'historique ; `None` : rien n'a changé (ou refus mémorisé). + fn compact( + &self, + phase: CompactPhase, + msgs: &[Message], + ) -> impl Future>> + Send; + /// Compaction en cours de tour déjà refusée pour cet état + /// (`compactRefusedSkip`) : on ne la retente pas. + fn compact_refused(&self, _used: i64, _msgs: &[Message]) -> bool { + false + } + /// Messages mis en file par l'utilisateur, à injecter maintenant. + fn drain_queued(&self) -> Vec; + /// 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 { + async {} + } + /// Image rangée (`loki-img:…`) → data-URL. + fn image_data_url(&self, r: &str) -> Option; + /// Intensité refusée par le gabarit : traduction retenue. + fn effort_remember(&self, want: &str, got: &str); + /// Gabarit qui refuse le raisonnement renvoyé : retenu pour ce modèle. + fn echo_refuse(&self, model: &str); + /// `noteEnginePrompt` (moteur local). + fn cache_hint(&self) -> Option { + None + } + /// Garde d'écriture du mode Code (`codeWriteGuard`). + fn preflight(&self) -> Option { + None + } +} + +/// Ce que le tour sait avant de commencer (lu UNE fois). +#[derive(Clone)] +pub struct TurnConfig { + /// `ReadConfig()`. + pub cfg: HashMap, + pub ep: ChatEndpoint, + pub tools: Vec, + pub temperature: f64, + /// Mode Code : palier du budget triplé, garde d'écriture. + pub code: bool, + pub reasoning: TurnReasoning, + /// Politique d'écho courante (`currentEchoPolicy`). + pub echo: EchoPolicy, + pub compact: CompactConfig, + pub patterns: Arc, +} + +/// Fin du tour : messages à ajouter à l'historique, et l'erreur s'il y en a +/// une (déjà émise dans le rappel). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct TurnResult { + pub extra: Vec, + pub err: Option, +} + +/// Arrêt du tour (bouton stop, reset, bascule). +#[derive(Clone)] +pub struct Cancel { + rx: watch::Receiver, + _keep: Option>>, +} + +impl Cancel { + /// Un arrêt et ce qui le déclenche (`send(true)`). + pub fn new() -> (watch::Sender, Cancel) { + let (tx, rx) = watch::channel(false); + (tx, Cancel { rx, _keep: None }) + } + + /// Jamais arrêté. + pub fn never() -> Cancel { + let (tx, rx) = watch::channel(false); + Cancel { + rx, + _keep: Some(Arc::new(tx)), + } + } + + pub fn is_cancelled(&self) -> bool { + *self.rx.borrow() + } + + /// Se résout à l'arrêt. + pub async fn cancelled(&self) { + let mut rx = self.rx.clone(); + if rx.wait_for(|c| *c).await.is_err() { + std::future::pending::<()>().await; + } + } + + /// `f`, ou `None` si l'arrêt arrive avant. + async fn or_stop(&self, f: F) -> Option { + tokio::select! { + biased; + _ = self.cancelled() => None, + v = f => Some(v), + } + } + + /// `llmNetBackoff` : attend le délai de la n-ième reprise ; faux si arrêté. + async fn backoff(&self, n: u32) -> bool { + self.or_stop(tokio::time::sleep(net_delay(n as i64))) + .await + .is_some() + } +} + +fn log_llm_retry(attempt: u32, cause: &str) { + eprintln!("[llm] {cause} — nouvelle tentative {attempt}/{NET_RETRIES}"); +} + +fn log_stream_retry(attempt: u32, cause: &str) { + eprintln!( + "[llm] flux coupé en cours de réponse ({cause}) — reprise {attempt}/{MAX_STREAM_RETRIES}" + ); +} + +fn text_msg(role: &str, text: impl Into) -> Message { + Message { + role: role.into(), + content: Some(Value::String(text.into())), + ..Default::default() + } +} + +/// `LLMPort` pour les messages d'erreur. +fn engine_port(cfg: &HashMap) -> u16 { + cfg.get("PORT") + .and_then(|p| p.trim().parse::().ok()) + .filter(|p| *p > 0) + .unwrap_or(8080) +} + +/// Lit au plus [`ERROR_BODY_MAX`] octets d'un corps de refus. +async fn read_error_body(mut body: BodyStream) -> String { + let mut buf = Vec::new(); + while buf.len() < ERROR_BODY_MAX { + match body.next().await { + Some(Ok(b)) => buf.extend_from_slice(&b), + _ => break, + } + } + buf.truncate(ERROR_BODY_MAX); + String::from_utf8_lossy(&buf).trim().to_string() +} + +fn status_text(status: u16) -> String { + let reason = hyper::StatusCode::from_u16(status) + .ok() + .and_then(|s| s.canonical_reason()) + .unwrap_or(""); + format!("{status} {reason}").trim().to_string() +} + +/// `fillToolResult` : aperçu pour l'UI, taille réelle, et l'id du résultat +/// complet s'il est coupé (envoyé entier si l'enregistrement échoue). +fn fill_tool_result(host: &H, mut ev: ToolUsedEvent, result: &str) -> ToolUsedEvent { + let (prev, n, cut) = tool_result_preview(result); + ev.result_chars = n as i64; + ev.result = result.to_string(); + if cut { + let id = host.save_tool_result(result); + if !id.is_empty() { + ev.result = prev.to_string(); + ev.result_id = id; + } + } + ev +} + +/// État d'un tour, d'une complétion à l'autre. +#[derive(Default)] +struct Loop { + disable_tools: bool, + tool_choice_none: bool, + compacted_retry: bool, + shrink_retries: u32, + effort_retried: bool, + done_calls: HashMap, + repeat_count: HashMap, + nudge_count: i64, + peak_gen: i64, + length_replays: u32, + last_est: i64, + pattern_retries: i64, + net_retries: u32, + stream_retries: u32, + echo_off_turn: bool, + echo_tpl_retry: bool, + tool_runs: i64, + budget_nudges: i64, +} + +/// `runChatLoop` : le tour, de la première requête à la réponse finale. +pub async fn run_turn( + host: &H, + tc: &TurnConfig, + mut messages: Vec, + cancel: &Cancel, + cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), +) -> TurnResult { + let mut extra: Vec = Vec::new(); + let mut s = Loop::default(); + let ep = &tc.ep; + let ccfg = &tc.compact; + let port = engine_port(&tc.cfg); + let reasoning_on = reasoning_active(tc.cfg.get("REASONING").map_or("", String::as_str)); + let effort_wanted = tc.reasoning.effort_wanted.clone(); + let think_off = tc.reasoning.think_off; + let mut effort = tc.reasoning.effort.clone(); + let mut kwargs = tc.reasoning.kwargs.clone(); + let mut budget = + super::nudges::agent_budget(tc.cfg.get("AGENT_BUDGET").map_or("", String::as_str)); + if tc.code { + budget *= super::nudges::CODE_BUDGET_FACTOR; + } + let new_history = |cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), m: &[Message]| { + cb(StreamEvent::NewHistory(m.to_vec())); + }; + let fail = + |cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), extra: Vec, err: String| { + cb(StreamEvent::Err(err.clone())); + TurnResult { + extra, + err: Some(err), + } + }; + + let mut iter: i64 = -1; + loop { + iter += 1; + // Messages mis en file : entre deux étapes, jamais après un stop. + if iter > 0 && !cancel.is_cancelled() { + let q = host.drain_queued(); + messages.extend(q.iter().cloned()); + extra.extend(q); + } + let nudge = budget_nudge(s.tool_runs, budget, s.budget_nudges); + if !nudge.is_empty() { + s.budget_nudges += 1; + log_budget(s.tool_runs, budget, s.budget_nudges); + 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()); + host.prepare_images(&messages).await; + let (body, echo_sent) = { + let (sent, echo_sent) = wire_messages(&messages, s.tool_choice_none, &pol); + let payload = build_chat_payload( + ep, + &sent, + tc.temperature, + &ChatPayloadOpts { + tools: &tc.tools, + disable_tools: s.disable_tools, + tool_choice_none: s.tool_choice_none, + effort: &effort, + kwargs: kwargs.as_ref(), + no_sampling: false, + }, + &tc.cfg, + &|r| host.image_data_url(r), + ); + (serde_json::to_vec(&payload).unwrap_or_default(), echo_sent) + }; + let t_req = Instant::now(); + let reply = match cancel.or_stop(host.send(ep, body)).await { + None => return TurnResult { extra, err: None }, + Some(Ok(r)) => r, + Some(Err(e)) => { + // Rien n'est encore à l'écran : rejouable tel quel. + if s.net_retries < NET_RETRIES && retryable(e.kind) && !cancel.is_cancelled() { + log_llm_retry(s.net_retries + 1, &e.message); + if cancel.backoff(s.net_retries).await { + s.net_retries += 1; + continue; + } + } + if cancel.is_cancelled() { + return TurnResult { extra, err: None }; + } + return fail(cb, extra, e.message); + } + }; + + if reply.status != 200 { + let status = reply.status; + let mut msg = read_error_body(reply.body).await; + if msg.is_empty() { + msg = status_text(status); + } + // La relance sans raisonnement est refusée à son tour : il n'y + // était pour rien. + s.echo_tpl_retry = false; + // Intensité refusée par le gabarit : refus définitif, à traiter + // avant toute compaction. + if !s.effort_retried + && !effort_wanted.is_empty() + && let Some(fixed) = effort_from_rejection(&msg, &effort_wanted) + { + s.effort_retried = true; + host.effort_remember(&effort_wanted, &fixed); + log_effort_fallback(&effort_wanted, &fixed); + kwargs = reasoning_template_kwargs(think_off, &fixed); + effort = fixed; + continue; + } + if echo_sent > 0 && echo_template_error(status, &msg) { + s.echo_off_turn = true; + s.echo_tpl_retry = true; + log_ctx(&format!( + "erreur de gabarit avec le raisonnement renvoyé ({status}) : relance sans lui" + )); + continue; + } + if echo_sent > 0 && context_overflow(ccfg, &msg, &messages) { + s.echo_off_turn = true; + log_ctx( + "prompt trop long avec le raisonnement renvoyé : relance sans lui avant toute compaction", + ); + continue; + } + if s.echo_off_turn { + messages = strip_reasoning(&messages).into_owned(); + } + let overflow = context_overflow(ccfg, &msg, &messages); + if ccfg.enabled && !s.compacted_retry && overflow { + let Some(c) = cancel + .or_stop(host.compact(CompactPhase::Reactive, &messages)) + .await + else { + return TurnResult { extra, err: None }; + }; + if let Some(c) = c { + s.compacted_retry = true; + log_compact(ccfg, "réactif", 0, &messages, &c, true); + messages = c; + extra.clear(); + new_history(cb, &messages); + continue; + } + } + if ccfg.enabled && s.shrink_retries < 2 && overflow { + let actual = overflow_tokens(&msg); + let (c, changed) = shrink_to_fit(ccfg, &messages, actual); + if changed { + s.shrink_retries += 1; + log_compact(ccfg, "réduction", actual, &messages, &c, true); + messages = c; + extra.clear(); + new_history(cb, &messages); + continue; + } + } + // 500 : appel d'outil que llama.cpp n'a pas su lire. Moteur local : + // d'abord tool_choice « none », prompt intact ; puis outils retirés. + if !s.disable_tools + && !tc.tools.is_empty() + && (status == 500 || (s.tool_choice_none && (400..500).contains(&status))) + { + if !ep.external && !s.tool_choice_none { + s.tool_choice_none = true; + log_ctx("relance sans outil après un 500 : tool_choice=none, prompt conservé"); + continue; + } + s.tool_choice_none = false; + s.disable_tools = true; + messages = steer_system(&messages, TOOLS_OFF_HINT); + continue; + } + // « Pas maintenant » (passerelle, surcharge) : rejouable. + if s.net_retries < NET_RETRIES && retryable_status(status) && !cancel.is_cancelled() { + log_llm_retry(s.net_retries + 1, &format!("le moteur a renvoyé {status}")); + if cancel.backoff(s.net_retries).await { + s.net_retries += 1; + continue; + } + } + let who = if ep.external { + "l'API externe" + } else { + "llama-server" + }; + return fail(cb, extra, format!("{who} a renvoyé {status} : {msg}")); + } + + // Une réponse est arrivée : le budget de reprise réseau repart à neuf. + s.net_retries = 0; + if s.echo_tpl_retry { + s.echo_tpl_retry = false; + host.echo_refuse(&reasoning_echo_model(&tc.cfg)); + } + + let mut parser = StreamParser::new(StreamConfig { + reasoning_on, + calls_on, + tool_choice_none: s.tool_choice_none, + external: ep.external, + echo_on: pol.on, + echo_keeps_past: pol.keeps_past, + pattern_retries: s.pattern_retries, + patterns: tc.patterns.clone(), + t_req, + cache_hint: if ep.external { None } else { host.cache_hint() }, + preflight: if tc.code { host.preflight() } else { None }, + }); + let mut emit = |ev: StreamEvent| cb(ev); + let mut lines = Lines::new(reply.body); + // (message, ligne trop longue) + let mut read_err: Option<(String, bool)> = None; + let mut stopped = false; + loop { + match cancel.or_stop(lines.next_line()).await { + None => { + stopped = true; + break; + } + Some(None) => break, + Some(Some(Ok(line))) => { + if !parser.feed_line(&line, Instant::now(), &mut emit) { + break; + } + } + Some(Some(Err(LineError::TooLong))) => { + read_err = Some(("bufio.Scanner: token too long".into(), true)); + break; + } + Some(Some(Err(LineError::Io(e)))) => { + read_err = Some((e, false)); + break; + } + } + } + // Arrêt pendant la lecture : le tour s'arrête là, sans erreur, et rien + // de plus ne part vers l'écran. + if stopped { + return TurnResult { extra, err: None }; + } + let out = parser.finish(&mut emit); + 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())); + 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 { + log_ctx_estimate("en-tour", s.last_est, out.stats.prompt_tokens_total); + } + } + s.last_est = 0; + } + if out.finish_reason == "length" { + log_ctx(&format!( + "finish=length prompt={} généré={} raisonnement={} fenêtre={}", + out.stats.prompt_tokens_total, + out.stats.gen_tokens, + out.stats.reasoning_tokens, + ccfg.ctx_window + )); + } + if out.aborted { + return TurnResult { extra, err: None }; + } + if read_err.is_some() && out.read_error_benign() { + read_err = None; + } + // Flux coupé vers une API distante : rejoué, ou poursuivi après le texte + // déjà affiché. Jamais pour le moteur local : coupé, il a planté. + if let Some((err, too_long)) = &read_err + && !too_long + && ep.external + && s.stream_retries < MAX_STREAM_RETRIES + { + s.stream_retries += 1; + if out.sent_reasoning && !out.sent_answer { + cb(StreamEvent::DropReasoning); + } + if let Some(t) = &out.typing_tool { + cb(StreamEvent::ToolUsed(ToolUsedEvent { + name: t.name.clone(), + label: t.label.clone(), + done: true, + result: "(appel interrompu par une coupure réseau, relancé)".into(), + ..Default::default() + })); + } + log_stream_retry(s.stream_retries, err); + if cancel.backoff(s.stream_retries).await { + if !out.shown.trim().is_empty() { + messages.push(text_msg("assistant", out.shown.clone())); + messages.push(text_msg("user", STREAM_CUT_CONTINUE)); + } + continue; + } + } + if let Some((err, too_long)) = read_err { + if cancel.is_cancelled() { + return TurnResult { extra, err: None }; + } + return fail(cb, extra, stream_cut_error(&err, too_long, port)); + } + s.stream_retries = 0; + + let content = out.content.as_str(); + // Relance tool_choice « none » sans réponse exploitable : repli UNE fois + // sur le chemin historique, outils retirés. + if s.tool_choice_none + && !cancel.is_cancelled() + && (out.none_leak + || tc.patterns.textual_tool_call(content) + || content.trim().is_empty()) + { + log_ctx("relance tool_choice=none sans réponse exploitable : repli sans outils"); + s.tool_choice_none = false; + s.disable_tools = true; + if out.sent_reasoning && !out.sent_answer { + cb(StreamEvent::DropReasoning); + } + messages = steer_system(&messages, TOOLS_OFF_HINT); + continue; + } + + let echo = ReasoningEcho { + text: out.echo_text.clone(), + model: pol.model.clone(), + }; + let assistant_base = |calls: Vec| { + let mut a = Message { + role: "assistant".into(), + tool_calls: calls, + ..Default::default() + }; + if !content.is_empty() { + a.content = Some(Value::String(content.to_string())); + } + if !echo.text.is_empty() { + a.reasoning_content = echo.text.clone(); + a.reasoning_model = echo.model.clone(); + } + a + }; + + // Écriture refusée en plein flux (pré-vol) : l'appel, réduit à son + // chemin, entre dans l'historique avec le refus pour résultat. + if let Some(pf) = &out.preflight { + let id = if pf.id.is_empty() { + format!("call_{iter}_0") + } else { + pf.id.clone() + }; + let file_arg = serde_json::json!({ "file": pf.file }).to_string(); + let assistant = assistant_base(vec![ToolCall { + id: id.clone(), + kind: "function".into(), + function: ToolCallFunc { + name: pf.name.clone(), + arguments: file_arg, + }, + ..Default::default() + }]); + let result = format!( + "{} (écriture interrompue avant la fin : rien n'a été modifié)", + pf.msg + ); + cb(StreamEvent::ToolUsed(ToolUsedEvent { + name: pf.name.clone(), + label: pf.file.clone(), + done: true, + result: result.clone(), + ..Default::default() + })); + let tool_msg = Message { + role: "tool".into(), + tool_call_id: id, + content: Some(Value::String(result)), + ..Default::default() + }; + messages.extend([assistant.clone(), tool_msg.clone()]); + extra.extend([assistant, tool_msg]); + s.tool_runs += 1; + continue; + } + + // Appels d'outils (même si finish_reason dit « stop »). + if !out.tool_calls.is_empty() { + let assembled = out.assemble_tool_calls(iter, &tc.tools); + let assistant = assistant_base(assembled.calls.clone()); + messages.push(assistant.clone()); + extra.push(assistant); + // Début des résultats de CETTE étape, que le moteur n'a pas comptés. + let step_start = messages.len(); + s.pattern_retries = 0; + for call in &assembled.calls { + if cancel.is_cancelled() { + return TurnResult { extra, err: None }; + } + s.tool_runs += 1; + let name = call.function.name.as_str(); + let args = call_args(&call.function.arguments); + let label = tool_call_label(name, &args); + cb(StreamEvent::ToolUsed(ToolUsedEvent { + name: name.into(), + label: label.clone(), + ..Default::default() + })); + let done_ev = || ToolUsedEvent { + name: name.into(), + label: label.clone(), + done: true, + ..Default::default() + }; + let push_result = + |messages: &mut Vec, extra: &mut Vec, content: String| { + let m = Message { + role: "tool".into(), + tool_call_id: call.id.clone(), + content: Some(Value::String(content)), + ..Default::default() + }; + messages.push(m.clone()); + extra.push(m); + }; + if assembled.bad_args.contains(&call.id) { + let result = "[erreur] arguments de l'appel illisibles (JSON invalide ou tronqué) : l'outil n'a PAS été exécuté. Renvoie l'appel avec des arguments JSON complets et valides."; + cb(StreamEvent::ToolUsed(fill_tool_result( + host, + done_ev(), + result, + ))); + push_result(&mut messages, &mut extra, result.into()); + continue; + } + let call_key = format!("{name}\0{}", call.function.arguments); + if let Some(prev) = s.done_calls.get(&call_key) + && dedupable_tool(name) + { + let n = s.repeat_count.entry(call_key.clone()).or_insert(0); + *n += 1; + let result = repeated_call_result(prev, *n); + cb(StreamEvent::ToolUsed(fill_tool_result( + host, + done_ev(), + &result, + ))); + push_result(&mut messages, &mut extra, result); + continue; + } + let Some(ex) = cancel.or_stop(host.exec_tool(name, &args, &label)).await else { + return TurnResult { extra, err: None }; + }; + if let Some(ask) = ex.ask.clone() { + cb(StreamEvent::Ask(ask)); + } + if !ex.result.starts_with("[erreur]") { + s.done_calls.insert(call_key, ex.result.clone()); + } + let ev = ToolUsedEvent { + diff: ex.diff.clone(), + added: ex.added, + removed: ex.removed, + image: ex.image.clone(), + shot: ex.live_shot.clone(), + ..done_ev() + }; + cb(StreamEvent::ToolUsed(fill_tool_result( + host, ev, &ex.result, + ))); + // Plafond pour le MODÈLE seulement : l'UI a reçu le résultat. + push_result(&mut messages, &mut extra, cap_tool_result(&ex.result)); + if let Some(img) = ex.relay { + messages.push(img); // éphémère : jamais dans l'historique + } + } + // Compaction EN COURS DE TOUR, sur le contexte réel du dernier appel + // plus les résultats de cette étape (le moteur ne les a pas vus). + let mut used = 0; + let base = out.stats.ctx_after(); + if base > 0 { + used = base + estimate_tokens(&messages[step_start..], &ccfg.echo); + s.last_est = used; + } + if compact_needed(ccfg, &messages, used, s.peak_gen) + && !host.compact_refused(used, &messages) + { + cb(StreamEvent::Compacting(true)); + let phase = CompactPhase::Mid { + used, + rescue: s.disable_tools || s.tool_choice_none, + }; + let c = cancel.or_stop(host.compact(phase, &messages)).await; + cb(StreamEvent::Compacting(false)); + let Some(c) = c else { + return TurnResult { extra, err: None }; + }; + log_compact( + ccfg, + "en-tour", + used, + &messages, + c.as_deref().unwrap_or(&messages), + c.is_some(), + ); + if let Some(c) = c { + s.last_est = 0; + messages = c; + extra.clear(); + new_history(cb, &messages); + } + } + continue; + } + + // Appel d'outil écrit en texte : relance corrective (le texte fautif + // reste affiché, l'historique garde la trace ET la correction). + let snippet = tc.patterns.snippet(content); + if !snippet.is_empty() && s.pattern_retries < MAX_PATTERN_RETRIES && calls_on { + s.pattern_retries += 1; + let bad = with_echo(text_msg("assistant", content), Some(&echo)); + let fix = text_msg("user", retry_corrective(&snippet)); + messages.extend([bad.clone(), fix.clone()]); + extra.extend([bad, fix]); + continue; + } + + if content.trim().is_empty() { + // Coupée par une fenêtre pleine : compacter et rejouer, une fois. + let known = out.stats.prompt_tokens_total + out.stats.gen_tokens; + if out.finish_reason == "length" + && !ep.external + && s.length_replays < 1 + && compact_would_trigger(ccfg, &messages, known) + { + s.length_replays += 1; + cb(StreamEvent::Compacting(true)); + let c = cancel + .or_stop(host.compact(CompactPhase::FullWindow, &messages)) + .await; + cb(StreamEvent::Compacting(false)); + let Some(c) = c else { + return TurnResult { extra, err: None }; + }; + log_compact( + ccfg, + "fenêtre-pleine", + known, + &messages, + c.as_deref().unwrap_or(&messages), + c.is_some(), + ); + if let Some(c) = c { + cb(StreamEvent::DropReasoning); + messages = c; + extra.clear(); + new_history(cb, &messages); + continue; + } + } + if calls_on && s.nudge_count < MAX_NUDGES { + s.nudge_count += 1; + log_ctx(&format!( + "relance « pensé sans agir » n°{} (finish={})", + s.nudge_count, out.finish_reason + )); + cb(StreamEvent::DropReasoning); + let nudge = if s.nudge_count > 1 { + THINK_NUDGE_STUCK + } else { + THINK_NUDGE_FIRST + }; + append_nudge(&mut messages, &mut extra, nudge); + continue; + } + cb(StreamEvent::Content(format!( + "_(le modèle n'a pas produit de réponse — finish: {})_", + out.finish_reason + ))); + } + // Raisonnement de la réponse finale, rangé avec elle (jamais sans texte). + if !echo.text.is_empty() && !content.trim().is_empty() { + cb(StreamEvent::Echo(echo)); + } + return TurnResult { extra, err: None }; + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/chat/turn/tests.rs b/rust/loki-rs/src/chat/turn/tests.rs new file mode 100644 index 0000000..50d6505 --- /dev/null +++ b/rust/loki-rs/src/chat/turn/tests.rs @@ -0,0 +1,500 @@ +//! Tests de la boucle d'un tour, sur un faux hôte aux réponses scriptées. + +use super::*; +use crate::chat::compact::CompactConfig; +use crate::chat::types::ToolFunction; +use crate::chat::wire::resolve_chat_endpoint; +use serde_json::json; +use std::collections::VecDeque; +use std::sync::Mutex; + +/// Une réponse scriptée du moteur. +enum Script { + Ok(Vec), + Status(u16, String), + Net(NetKind), + /// Corps qui s'interrompt sur une erreur de lecture après ces lignes. + Cut(Vec), +} + +#[derive(Default)] +struct Fake { + script: Mutex>, + bodies: Mutex>, + tools: Mutex>, + execs: Mutex>, + compacted: Mutex>>, + phases: Mutex>, + queued: Mutex>, + efforts: Mutex>, + /// L'arrêt à déclencher pendant l'exécution d'un outil. + stop_on_exec: Mutex>>, +} + +impl Fake { + fn new(script: Vec