mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Merge branch 'claude/awesome-albattani-q0do3m' : lots 6 et 7, conversation et outils tenus par la façade Rust (LOKI_CHAT=rust)
Conversation, tours, routes du chat et outils fichiers/shell/git en Rust derrière LOKI_CHAT=rust (expérimental, Go par défaut) ; boucle du contrat du mode Code ; changement de projet par la façade ; parité Go/Rust par journaux de référence (19 tours, 7 tours complets, 70 appels d'outils). Repris de la PR #25 : le sélecteur de l'en-tête ne charge que des presets.
This commit is contained in:
98 files changed
+29579
-203
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:<id>`) ; 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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+29
-14
@@ -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
|
||||
|
||||
@@ -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/<nom>.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(`<tool_call>{"name":"read","arguments":{"file":"a.txt"}}</tool_call>`), 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("<think>Il dit bonjour."), pContent("</think>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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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/<nom>.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-52
@@ -2407,7 +2407,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
||||
</button>
|
||||
<div id="topbar-title">Nouvelle discussion</div>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="preset actif — en changer recharge tous ses réglages et relance le moteur" aria-label="preset actif"></select>
|
||||
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
@@ -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é';
|
||||
|
||||
@@ -122,7 +122,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
||||
</button>
|
||||
<div id="topbar-title">Nouvelle discussion</div>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="preset actif — en changer recharge tous ses réglages et relance le moteur" aria-label="preset actif"></select>
|
||||
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -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); }
|
||||
@@ -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é';
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 : ",
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+39
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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<std::sync::Arc<crate::chat::generate::ChatService>>,
|
||||
/// 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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -98,8 +98,8 @@ const THINK_CLOSE: &str = "</think>";
|
||||
// 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 <context_update> (chat_projsnap.go).
|
||||
const CTX_UPD_OPEN: &str = "<context_update from=\"loki\"";
|
||||
const CTX_UPD_CLOSE: &str = "</context_update>";
|
||||
pub(crate) const CTX_UPD_OPEN: &str = "<context_update from=\"loki\"";
|
||||
pub(crate) const CTX_UPD_CLOSE: &str = "</context_update>";
|
||||
|
||||
// ---- configuration -------------------------------------------------------
|
||||
|
||||
@@ -391,7 +391,7 @@ pub fn log_compact(
|
||||
// ---- rappels de loki, images gardées, blocs projet ------------------------
|
||||
|
||||
/// `isLokiInjected` : un message `user` venu de loki, pas de l'utilisateur.
|
||||
fn is_loki_injected(m: &Message) -> 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 <context_update> de tête
|
||||
/// (seulement un message `user` marqué `ctx_upd`).
|
||||
fn without_ctx_update(m: &Message) -> Option<Message> {
|
||||
pub(crate) fn without_ctx_update(m: &Message) -> Option<Message> {
|
||||
if m.role != "user" || !m.ctx_upd {
|
||||
return None;
|
||||
}
|
||||
@@ -504,7 +504,7 @@ fn without_ctx_update(m: &Message) -> Option<Message> {
|
||||
}
|
||||
|
||||
/// `stripContextUpdates` : l'historique sans aucun bloc <context_update>.
|
||||
fn strip_context_updates(msgs: Vec<Message>) -> Vec<Message> {
|
||||
pub(crate) fn strip_context_updates(msgs: Vec<Message>) -> Vec<Message> {
|
||||
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<String, String>;
|
||||
fn archive(
|
||||
&self,
|
||||
label: &str,
|
||||
role: &str,
|
||||
content: &str,
|
||||
) -> impl Future<Output = Result<String, String>> + 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<String, String> {
|
||||
async fn archive(&self, _label: &str, _role: &str, _content: &str) -> Result<String, String> {
|
||||
Err("archive recall indisponible".into())
|
||||
}
|
||||
}
|
||||
@@ -1587,7 +1592,7 @@ impl<S: Summarizer, R: RecallArchive> 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<String, String> {
|
||||
async fn archive(&self, label: &str, role: &str, content: &str) -> Result<String, String> {
|
||||
let mut b = self.blocks.lock().unwrap();
|
||||
b.push((label.into(), role.into(), content.into()));
|
||||
Ok(format!("r{}", b.len()))
|
||||
|
||||
@@ -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<dyn FnOnce() + Send>;
|
||||
|
||||
/// 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<CancelTurn>,
|
||||
}
|
||||
|
||||
/// La discussion ouverte. À partager dans un [`Arc`] (abonnés, tour).
|
||||
pub struct Conversation {
|
||||
inner: Mutex<Inner>,
|
||||
version: watch::Sender<u64>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<i64, Busy> {
|
||||
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<R>(&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<R>(&self, epoch: i64, f: impl FnOnce(&mut ConvState) -> R) -> Option<R> {
|
||||
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<S: PersistSink>(&self, p: &Persister<S>, 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<String, Value> {
|
||||
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<Self>, 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<Conversation>,
|
||||
rx: watch::Receiver<u64>,
|
||||
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<Delta> {
|
||||
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<Vec<Delta>> {
|
||||
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<Delta> {
|
||||
tokio::time::timeout(Duration::from_secs(5), sub.next())
|
||||
.await
|
||||
.expect("aucun lot")
|
||||
}
|
||||
|
||||
fn seqs(evs: &[Delta]) -> Vec<i64> {
|
||||
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<ConvSnap>,
|
||||
_: Vec<ToolResJob>,
|
||||
_: 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);
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<Message>, prefix: &str) -> Vec<Message> {
|
||||
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<Message> {
|
||||
let mut own: HashMap<String, usize> = 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<Message>) -> Vec<Message> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> = 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<Message> {
|
||||
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<Message>) -> Vec<Message> {
|
||||
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<Message>,
|
||||
source: &[Message],
|
||||
mem_off: bool,
|
||||
) -> Vec<Message> {
|
||||
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<String, Value> {
|
||||
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<Message>, Option<Vec<Message>>);
|
||||
let cases: Vec<Case> = 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("<tool_call>"),
|
||||
um(&retry_corrective("<tool_call>")),
|
||||
],
|
||||
Some(vec![um("q"), am("<tool_call>")]),
|
||||
),
|
||||
(
|
||||
"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<String> = (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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>,
|
||||
order: VecDeque<String>,
|
||||
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<ImgCacheInner>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String, String>,
|
||||
pub patterns: Arc<RetryPatterns>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl EngineSummarizer {
|
||||
async fn post(&self, payload: Value) -> Result<SummaryChoice, SummaryError> {
|
||||
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<SummaryChoice, SummaryError> {
|
||||
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<SummaryChoice, SummaryError> {
|
||||
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<String, String> {
|
||||
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<StoreSink>,
|
||||
pub effort: EffortFallbacks,
|
||||
pub echo_refusals: EchoRefusals,
|
||||
pub compact_memory: CompactMemory,
|
||||
pub images: ImageCache,
|
||||
pub patterns: Arc<RetryPatterns>,
|
||||
}
|
||||
|
||||
/// 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<Tool>,
|
||||
pub effort: String,
|
||||
pub kwargs: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Le [`TurnHost`] de la façade.
|
||||
pub struct GoTurnHost {
|
||||
pub shared: Arc<ChatShared>,
|
||||
/// 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<String, String>,
|
||||
pub port: u16,
|
||||
pub summarizer: EngineSummarizer,
|
||||
pub compact: TurnCompact,
|
||||
/// Messages mis en file par l'utilisateur pendant le tour.
|
||||
pub queued: Box<dyn Fn() -> Vec<Message> + Send + Sync>,
|
||||
}
|
||||
|
||||
/// `toolResID` : « <discussion>.<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<Message> = 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::<Vec<DiffLine>>(d.clone()).ok())
|
||||
.unwrap_or_default(),
|
||||
added: n("added"),
|
||||
removed: n("removed"),
|
||||
ask: v
|
||||
.get("ask")
|
||||
.and_then(|a| serde_json::from_value::<AskEvent>(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<u8>) -> Result<Reply, SendError> {
|
||||
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<String, Value>, 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<Vec<Message>> {
|
||||
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<Message> {
|
||||
(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<String> {
|
||||
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<CacheHintFn> {
|
||||
None
|
||||
}
|
||||
|
||||
fn preflight(&self) -> Option<PreflightFn> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
task_name: Option<String>,
|
||||
},
|
||||
/// 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<LeaseSignal>,
|
||||
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<Self, LeaseError> {
|
||||
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<LeaseSignal>) {
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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::<i64>()
|
||||
.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<Message>, extra: &mut Vec<Message>, 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<Message> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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:<id>`), 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<Output = ()> + Send;
|
||||
/// Bascule faite (`prewarmAfterSwitch`).
|
||||
fn switched(&self, id: &str) -> impl Future<Output = ()> + 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<Output = ()> + 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<Output = Result<(), String>> + 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<IndexError> 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<H: SessionHooks> {
|
||||
store: Store,
|
||||
conv: Arc<Conversation>,
|
||||
persister: Persister<StoreSink>,
|
||||
hooks: H,
|
||||
/// `convOpMu`.
|
||||
op: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl<H: SessionHooks> Sessions<H> {
|
||||
pub fn new(store: Store, conv: Arc<Conversation>, 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<Conversation> {
|
||||
&self.conv
|
||||
}
|
||||
|
||||
pub fn persister(&self) -> &Persister<StoreSink> {
|
||||
&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<ConvMeta> {
|
||||
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<bool, String> {
|
||||
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<String, String> {
|
||||
let _op = self.op.lock().await;
|
||||
self.ensure_active_locked().await
|
||||
}
|
||||
|
||||
async fn ensure_active_locked(&self) -> Result<String, String> {
|
||||
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<ConvMeta>, 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<String, SessionError> {
|
||||
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<String, SessionError> {
|
||||
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<usize, SessionError> {
|
||||
let ids: Vec<String> = 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Box<dyn Stream<Item = Result<Bytes, String>> + 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<DiffLine>,
|
||||
pub added: i64,
|
||||
pub removed: i64,
|
||||
pub ask: Option<AskEvent>,
|
||||
/// 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<Message>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
) -> impl Future<Output = Result<Reply, SendError>> + Send;
|
||||
/// Exécute un appel d'outil (`execTool`).
|
||||
fn exec_tool(
|
||||
&self,
|
||||
name: &str,
|
||||
args: &Map<String, Value>,
|
||||
label: &str,
|
||||
) -> impl Future<Output = ToolOutcome> + 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<Output = Option<Vec<Message>>> + 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<Message>;
|
||||
/// Prépare ce que [`Self::image_data_url`] devra rendre pour ces messages
|
||||
/// (lecture et déchiffrement des images rangées), avant chaque requête.
|
||||
fn prepare_images(&self, _msgs: &[Message]) -> impl Future<Output = ()> + Send {
|
||||
async {}
|
||||
}
|
||||
/// Image rangée (`loki-img:…`) → data-URL.
|
||||
fn image_data_url(&self, r: &str) -> Option<String>;
|
||||
/// 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<CacheHintFn> {
|
||||
None
|
||||
}
|
||||
/// Garde d'écriture du mode Code (`codeWriteGuard`).
|
||||
fn preflight(&self) -> Option<PreflightFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Ce que le tour sait avant de commencer (lu UNE fois).
|
||||
#[derive(Clone)]
|
||||
pub struct TurnConfig {
|
||||
/// `ReadConfig()`.
|
||||
pub cfg: HashMap<String, String>,
|
||||
pub ep: ChatEndpoint,
|
||||
pub tools: Vec<Tool>,
|
||||
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<RetryPatterns>,
|
||||
}
|
||||
|
||||
/// 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<Message>,
|
||||
pub err: Option<String>,
|
||||
}
|
||||
|
||||
/// Arrêt du tour (bouton stop, reset, bascule).
|
||||
#[derive(Clone)]
|
||||
pub struct Cancel {
|
||||
rx: watch::Receiver<bool>,
|
||||
_keep: Option<Arc<watch::Sender<bool>>>,
|
||||
}
|
||||
|
||||
impl Cancel {
|
||||
/// Un arrêt et ce qui le déclenche (`send(true)`).
|
||||
pub fn new() -> (watch::Sender<bool>, 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<F: Future>(&self, f: F) -> Option<F::Output> {
|
||||
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<String>) -> Message {
|
||||
Message {
|
||||
role: role.into(),
|
||||
content: Some(Value::String(text.into())),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `LLMPort` pour les messages d'erreur.
|
||||
fn engine_port(cfg: &HashMap<String, String>) -> u16 {
|
||||
cfg.get("PORT")
|
||||
.and_then(|p| p.trim().parse::<u16>().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<H: TurnHost>(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<String, String>,
|
||||
repeat_count: HashMap<String, i64>,
|
||||
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<H: TurnHost>(
|
||||
host: &H,
|
||||
tc: &TurnConfig,
|
||||
mut messages: Vec<Message>,
|
||||
cancel: &Cancel,
|
||||
cb: &mut (dyn FnMut(StreamEvent) -> bool + Send),
|
||||
) -> TurnResult {
|
||||
let mut extra: Vec<Message> = 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<Message>, 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<ToolCall>| {
|
||||
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<Message>, extra: &mut Vec<Message>, 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;
|
||||
@@ -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<String>),
|
||||
Status(u16, String),
|
||||
Net(NetKind),
|
||||
/// Corps qui s'interrompt sur une erreur de lecture après ces lignes.
|
||||
Cut(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
script: Mutex<VecDeque<Script>>,
|
||||
bodies: Mutex<Vec<Value>>,
|
||||
tools: Mutex<HashMap<String, String>>,
|
||||
execs: Mutex<Vec<String>>,
|
||||
compacted: Mutex<Option<Vec<Message>>>,
|
||||
phases: Mutex<Vec<CompactPhase>>,
|
||||
queued: Mutex<Vec<Message>>,
|
||||
efforts: Mutex<Vec<(String, String)>>,
|
||||
/// L'arrêt à déclencher pendant l'exécution d'un outil.
|
||||
stop_on_exec: Mutex<Option<watch::Sender<bool>>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new(script: Vec<Script>) -> Self {
|
||||
Fake {
|
||||
script: Mutex::new(script.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn body(&self, i: usize) -> Value {
|
||||
self.bodies.lock().unwrap()[i].clone()
|
||||
}
|
||||
fn sent(&self) -> usize {
|
||||
self.bodies.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_of(lines: Vec<String>, cut: bool) -> BodyStream {
|
||||
let mut items: Vec<Result<Bytes, String>> = lines
|
||||
.into_iter()
|
||||
.map(|l| Ok(Bytes::from(format!("{l}\n"))))
|
||||
.collect();
|
||||
if cut {
|
||||
items.push(Err("connection reset".into()));
|
||||
}
|
||||
Box::pin(futures_util::stream::iter(items))
|
||||
}
|
||||
|
||||
impl TurnHost for Fake {
|
||||
async fn send(&self, _ep: &ChatEndpoint, body: Vec<u8>) -> Result<Reply, SendError> {
|
||||
self.bodies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(serde_json::from_slice(&body).unwrap());
|
||||
match self
|
||||
.script
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("requête non scriptée")
|
||||
{
|
||||
Script::Ok(l) => Ok(Reply {
|
||||
status: 200,
|
||||
body: stream_of(l, false),
|
||||
}),
|
||||
Script::Cut(l) => Ok(Reply {
|
||||
status: 200,
|
||||
body: stream_of(l, true),
|
||||
}),
|
||||
Script::Status(s, b) => Ok(Reply {
|
||||
status: s,
|
||||
body: stream_of(vec![b], false),
|
||||
}),
|
||||
Script::Net(kind) => Err(SendError {
|
||||
kind,
|
||||
message: format!("panne {kind:?}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, _label: &str) -> ToolOutcome {
|
||||
self.execs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("{name} {}", Value::Object(args.clone())));
|
||||
let stop = self.stop_on_exec.lock().unwrap().take();
|
||||
if let Some(tx) = stop {
|
||||
let _ = tx.send(true);
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
let result = self
|
||||
.tools
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("résultat de {name}"));
|
||||
ToolOutcome {
|
||||
result,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn save_tool_result(&self, _result: &str) -> String {
|
||||
"res1".into()
|
||||
}
|
||||
async fn compact(&self, phase: CompactPhase, _msgs: &[Message]) -> Option<Vec<Message>> {
|
||||
self.phases.lock().unwrap().push(phase);
|
||||
self.compacted.lock().unwrap().clone()
|
||||
}
|
||||
fn drain_queued(&self) -> Vec<Message> {
|
||||
std::mem::take(&mut *self.queued.lock().unwrap())
|
||||
}
|
||||
fn image_data_url(&self, _r: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn effort_remember(&self, want: &str, got: &str) {
|
||||
self.efforts.lock().unwrap().push((want.into(), got.into()));
|
||||
}
|
||||
fn echo_refuse(&self, _model: &str) {}
|
||||
}
|
||||
|
||||
fn content(s: &str) -> String {
|
||||
format!("data: {}", json!({"choices":[{"delta":{"content": s}}]}))
|
||||
}
|
||||
fn finish(reason: &str) -> String {
|
||||
format!(
|
||||
"data: {}",
|
||||
json!({"choices":[{"delta":{},"finish_reason": reason}],"usage":{"prompt_tokens":100,"completion_tokens":5}})
|
||||
)
|
||||
}
|
||||
fn call(id: &str, name: &str, args: &str) -> String {
|
||||
format!(
|
||||
"data: {}",
|
||||
json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":id,"type":"function","function":{"name":name,"arguments":args}}]}}]})
|
||||
)
|
||||
}
|
||||
fn answer(s: &str) -> Script {
|
||||
Script::Ok(vec![content(s), finish("stop")])
|
||||
}
|
||||
fn tool_step(id: &str, name: &str, args: &str) -> Script {
|
||||
Script::Ok(vec![call(id, name, args), finish("tool_calls")])
|
||||
}
|
||||
|
||||
fn tool(name: &str) -> Tool {
|
||||
Tool {
|
||||
kind: "function".into(),
|
||||
function: ToolFunction {
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parameters: json!({"type":"object"}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn config(tools: &[&str]) -> TurnConfig {
|
||||
let cfg = HashMap::new();
|
||||
TurnConfig {
|
||||
ep: resolve_chat_endpoint(&cfg, ""),
|
||||
cfg,
|
||||
tools: tools.iter().map(|n| tool(n)).collect(),
|
||||
temperature: 0.7,
|
||||
code: false,
|
||||
reasoning: TurnReasoning::default(),
|
||||
echo: EchoPolicy::default(),
|
||||
compact: CompactConfig::default(),
|
||||
patterns: RetryPatterns::builtin(),
|
||||
}
|
||||
}
|
||||
|
||||
fn um(s: &str) -> Message {
|
||||
text_msg("user", s)
|
||||
}
|
||||
|
||||
/// Lance un tour et rend (résultat, événements).
|
||||
async fn run(host: &Fake, tc: &TurnConfig, cancel: &Cancel) -> (TurnResult, Vec<StreamEvent>) {
|
||||
let mut evs = Vec::new();
|
||||
let mut cb = |ev: StreamEvent| {
|
||||
evs.push(ev);
|
||||
true
|
||||
};
|
||||
let r = run_turn(host, tc, vec![um("question")], cancel, &mut cb).await;
|
||||
(r, evs)
|
||||
}
|
||||
|
||||
fn text(evs: &[StreamEvent]) -> String {
|
||||
evs.iter()
|
||||
.filter_map(|e| match e {
|
||||
StreamEvent::Content(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tools_done(evs: &[StreamEvent]) -> Vec<(String, String)> {
|
||||
evs.iter()
|
||||
.filter_map(|e| match e {
|
||||
StreamEvent::ToolUsed(t) if t.done => Some((t.name.clone(), t.result.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn roles(m: &[Message]) -> Vec<&str> {
|
||||
m.iter().map(|m| m.role.as_str()).collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reponse_directe() {
|
||||
let host = Fake::new(vec![answer("Bonjour")]);
|
||||
let (r, evs) = run(&host, &config(&[]), &Cancel::never()).await;
|
||||
assert_eq!(r, TurnResult::default());
|
||||
assert_eq!(text(&evs), "Bonjour");
|
||||
assert!(evs.iter().any(|e| matches!(e, StreamEvent::Stats(_))));
|
||||
let b = host.body(0);
|
||||
assert_eq!(b["messages"][0]["content"], "question");
|
||||
assert_eq!(b["stream"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outil_puis_reponse() {
|
||||
let host = Fake::new(vec![
|
||||
tool_step("c1", "glob", r#"{"pattern":"*.go"}"#),
|
||||
answer("Trouvé"),
|
||||
]);
|
||||
let (r, evs) = run(&host, &config(&["glob"]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(roles(&r.extra), ["assistant", "tool"]);
|
||||
assert_eq!(r.extra[0].tool_calls[0].id, "c1");
|
||||
assert_eq!(r.extra[1].tool_call_id, "c1");
|
||||
assert_eq!(r.extra[1].content, Some(json!("résultat de glob")));
|
||||
assert_eq!(*host.execs.lock().unwrap(), [r#"glob {"pattern":"*.go"}"#]);
|
||||
assert_eq!(
|
||||
tools_done(&evs),
|
||||
[("glob".to_string(), "résultat de glob".to_string())]
|
||||
);
|
||||
assert_eq!(text(&evs), "Trouvé");
|
||||
// La seconde requête porte l'appel et son résultat.
|
||||
assert_eq!(roles_of(&host.body(1)), ["user", "assistant", "tool"]);
|
||||
}
|
||||
|
||||
fn roles_of(b: &Value) -> Vec<String> {
|
||||
b["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| m["role"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appel_identique_court_circuite_et_arguments_illisibles() {
|
||||
let host = Fake::new(vec![
|
||||
tool_step("a", "edit", r#"{"file":"x"}"#),
|
||||
tool_step("b", "edit", r#"{"file":"x"}"#),
|
||||
tool_step("c", "edit", r#"{"file":"#),
|
||||
answer("fini"),
|
||||
]);
|
||||
let (r, evs) = run(&host, &config(&["edit"]), &Cancel::never()).await;
|
||||
assert_eq!(
|
||||
host.execs.lock().unwrap().len(),
|
||||
1,
|
||||
"rejoué ou exécuté sur JSON cassé"
|
||||
);
|
||||
let done = tools_done(&evs);
|
||||
assert!(done[1].1.starts_with("[déjà fait]"));
|
||||
assert!(
|
||||
done[2]
|
||||
.1
|
||||
.starts_with("[erreur] arguments de l'appel illisibles")
|
||||
);
|
||||
assert_eq!(r.extra.len(), 6);
|
||||
assert_eq!(r.extra[4].tool_calls[0].function.arguments, "{}");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn reprise_reseau_puis_reponse() {
|
||||
let host = Fake::new(vec![
|
||||
Script::Net(NetKind::Refused),
|
||||
Script::Status(503, "occupé".into()),
|
||||
answer("ok"),
|
||||
]);
|
||||
let (r, evs) = run(&host, &config(&[]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(text(&evs), "ok");
|
||||
assert_eq!(host.sent(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn panne_reseau_durable_rend_l_erreur() {
|
||||
let host = Fake::new((0..4).map(|_| Script::Net(NetKind::Timeout)).collect());
|
||||
let (r, evs) = run(&host, &config(&[]), &Cancel::never()).await;
|
||||
assert_eq!(r.err.as_deref(), Some("panne Timeout"));
|
||||
assert!(matches!(evs.last(), Some(StreamEvent::Err(e)) if e == "panne Timeout"));
|
||||
assert_eq!(host.sent(), 1 + NET_RETRIES as usize);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refus_definitif_nomme_la_machine() {
|
||||
let host = Fake::new(vec![Script::Status(401, "clé refusée".into())]);
|
||||
let (r, _) = run(&host, &config(&[]), &Cancel::never()).await;
|
||||
assert_eq!(
|
||||
r.err.as_deref(),
|
||||
Some("llama-server a renvoyé 401 : clé refusée")
|
||||
);
|
||||
let host = Fake::new(vec![Script::Status(404, String::new())]);
|
||||
let mut tc = config(&[]);
|
||||
tc.ep.external = true;
|
||||
let (r, _) = run(&host, &tc, &Cancel::never()).await;
|
||||
assert_eq!(
|
||||
r.err.as_deref(),
|
||||
Some("l'API externe a renvoyé 404 : 404 Not Found")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erreur_500_tool_choice_none_puis_outils_retires() {
|
||||
let host = Fake::new(vec![
|
||||
Script::Status(500, "parse error".into()),
|
||||
Script::Status(500, "encore".into()),
|
||||
answer("en texte"),
|
||||
]);
|
||||
let (r, evs) = run(&host, &config(&["glob"]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(text(&evs), "en texte");
|
||||
assert_eq!(host.body(1)["tool_choice"], "none");
|
||||
assert!(
|
||||
host.body(1)["tools"].is_array(),
|
||||
"outils gardés : prompt intact"
|
||||
);
|
||||
assert!(host.body(2).get("tools").is_none(), "puis retirés");
|
||||
assert_eq!(host.body(2)["messages"][0]["role"], "system");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debordement_compaction_reactive() {
|
||||
let host = Fake::new(vec![
|
||||
Script::Status(400, "the request exceeds the available context size".into()),
|
||||
answer("après compaction"),
|
||||
]);
|
||||
*host.compacted.lock().unwrap() = Some(vec![um("résumé")]);
|
||||
let (r, evs) = run(&host, &config(&[]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(*host.phases.lock().unwrap(), [CompactPhase::Reactive]);
|
||||
assert!(
|
||||
evs.iter()
|
||||
.any(|e| matches!(e, StreamEvent::NewHistory(h) if h == &vec![um("résumé")]))
|
||||
);
|
||||
assert_eq!(host.body(1)["messages"][0]["content"], "résumé");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intensite_refusee_par_le_gabarit() {
|
||||
let refus = "Jinja Exception: Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low.";
|
||||
let host = Fake::new(vec![Script::Status(500, refus.into()), answer("ok")]);
|
||||
let mut tc = config(&[]);
|
||||
tc.reasoning = TurnReasoning {
|
||||
effort_wanted: "high".into(),
|
||||
effort: "high".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (r, _) = run(&host, &tc, &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(host.body(0)["reasoning_effort"], "high");
|
||||
assert_eq!(host.body(1)["reasoning_effort"], "xhigh");
|
||||
assert_eq!(
|
||||
*host.efforts.lock().unwrap(),
|
||||
[("high".to_string(), "xhigh".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reponse_vide_relances_puis_message() {
|
||||
let empty = || Script::Ok(vec![finish("stop")]);
|
||||
let host = Fake::new(vec![empty(), empty(), empty()]);
|
||||
let (r, evs) = run(&host, &config(&["glob"]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
let drops = evs
|
||||
.iter()
|
||||
.filter(|e| matches!(e, StreamEvent::DropReasoning))
|
||||
.count();
|
||||
assert_eq!(drops, 2);
|
||||
let last = host.body(2)["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(last["content"], THINK_NUDGE_STUCK);
|
||||
assert_eq!(
|
||||
text(&evs),
|
||||
"_(le modèle n'a pas produit de réponse — finish: stop)_"
|
||||
);
|
||||
// Sans outils, pas de relance.
|
||||
let host = Fake::new(vec![empty()]);
|
||||
let (_, evs) = run(&host, &config(&[]), &Cancel::never()).await;
|
||||
assert_eq!(host.sent(), 1);
|
||||
assert!(text(&evs).contains("pas produit de réponse"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn appel_ecrit_en_texte_relance_corrective() {
|
||||
let host = Fake::new(vec![
|
||||
answer("<tool_call>{\"name\":\"glob\",\"arguments\":{}}</tool_call>"),
|
||||
answer("vraie réponse"),
|
||||
]);
|
||||
let (r, _) = run(&host, &config(&["glob"]), &Cancel::never()).await;
|
||||
assert_eq!(roles(&r.extra), ["assistant", "user"]);
|
||||
assert!(
|
||||
r.extra[1]
|
||||
.content
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("Your last answer contained a TOOL CALL WRITTEN AS TEXT")
|
||||
);
|
||||
assert_eq!(host.sent(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_d_attente_injectee_entre_deux_etapes() {
|
||||
let host = Fake::new(vec![tool_step("c1", "glob", "{}"), answer("ok")]);
|
||||
*host.queued.lock().unwrap() = vec![um("et aussi ça")];
|
||||
let (r, _) = run(&host, &config(&["glob"]), &Cancel::never()).await;
|
||||
assert_eq!(roles(&r.extra), ["assistant", "tool", "user"]);
|
||||
assert_eq!(
|
||||
roles_of(&host.body(1)),
|
||||
["user", "assistant", "tool", "user"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_d_outils_rappel() {
|
||||
let mut script: Vec<Script> = (0..3)
|
||||
.map(|i| {
|
||||
tool_step(
|
||||
&format!("c{i}"),
|
||||
"bash",
|
||||
&format!("{{\"command\":\"ls {i}\"}}"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
script.push(answer("fin"));
|
||||
let host = Fake::new(script);
|
||||
let mut tc = config(&["bash"]);
|
||||
tc.cfg.insert("AGENT_BUDGET".into(), "2".into());
|
||||
let (r, _) = run(&host, &tc, &Cancel::never()).await;
|
||||
// Après 2 appels, le rappel part en fin de fil.
|
||||
let last = host.body(2)["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert!(last["content"].as_str().unwrap().contains("2 tool calls"));
|
||||
assert!(r.extra.iter().any(|m| {
|
||||
m.content
|
||||
.as_ref()
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| s.contains("2 tool calls"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn arret_pendant_un_outil() {
|
||||
let host = Fake::new(vec![tool_step("c1", "bash", r#"{"command":"sleep 100"}"#)]);
|
||||
let (tx, cancel) = Cancel::new();
|
||||
*host.stop_on_exec.lock().unwrap() = Some(tx);
|
||||
let (r, evs) = run(&host, &config(&["bash"]), &cancel).await;
|
||||
assert_eq!(r.err, None, "un arrêt n'est pas une erreur");
|
||||
assert_eq!(roles(&r.extra), ["assistant"]);
|
||||
assert!(!evs.iter().any(|e| matches!(e, StreamEvent::Err(_))));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn flux_externe_coupe_repris_apres_le_texte_affiche() {
|
||||
let host = Fake::new(vec![Script::Cut(vec![content("Début ")]), answer("suite")]);
|
||||
let mut tc = config(&[]);
|
||||
tc.ep.external = true;
|
||||
let (r, evs) = run(&host, &tc, &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(text(&evs), "Début suite");
|
||||
let msgs = host.body(1)["messages"].as_array().unwrap().clone();
|
||||
assert_eq!(msgs[1]["content"], "Début ");
|
||||
assert_eq!(msgs[2]["content"], STREAM_CUT_CONTINUE);
|
||||
// Moteur local coupé : erreur, pas de reprise.
|
||||
let host = Fake::new(vec![Script::Cut(vec![content("x")])]);
|
||||
let (r, _) = run(&host, &config(&[]), &Cancel::never()).await;
|
||||
assert!(r.err.unwrap().contains("a été coupé en cours de route"));
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//! La boucle du contrat du mode Code — port de `code_verify.go` (lot 7).
|
||||
//!
|
||||
//! À la fin d'un tour de build en mode Code, tant que les critères
|
||||
//! d'acceptation ne sont pas tous `passed` : le builder est d'abord relancé
|
||||
//! sur ses critères encore ouverts (au plus [`CODE_MAX_CONTINUE`] fois), puis
|
||||
//! une passe de VÉRIFICATION tourne sur un contexte isolé (prompt du rôle +
|
||||
//! critères + tâche, pas l'historique) — seule autorisée à marquer un critère
|
||||
//! `passed` —, suivie au besoin d'une correction par le builder sur
|
||||
//! l'historique normal, au plus [`CODE_MAX_FIX_TURNS`] fois.
|
||||
//!
|
||||
//! Les critères restent au Go (l'outil `criteria` s'y exécute) : ils sont lus
|
||||
//! en base (`chat/crit:<id>`, chiffrés comme le reste) ; le contexte isolé du
|
||||
//! vérificateur vient de `/internal/v1/turn/verifier`.
|
||||
//!
|
||||
//! Écart assumé : l'isolation du slot du moteur après la passe de
|
||||
//! vérification (`engineSideJob`, effacement du cache) reste au Go, qui ne
|
||||
//! voit pas passer les requêtes de la façade.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::compact::{CompactCaps, is_loki_injected, strip_context_updates, without_ctx_update};
|
||||
use super::generate::{ChatService, TurnEnv, obj};
|
||||
use super::history::{drop_stray_nudges, history_from_view, inject_prefix, tool_used_delta};
|
||||
use super::host::{GoTurnHost, TurnCompact};
|
||||
use super::lease::{EngineLease, LeaseSignal};
|
||||
use super::persist::BK_CHAT;
|
||||
use super::turn::{Cancel, TurnConfig, run_turn};
|
||||
use super::types::{Message, ReasoningEcho, StreamEvent, Tool};
|
||||
use super::wire::{TurnReasoning, with_echo};
|
||||
use crate::store::Store;
|
||||
|
||||
/// Tours de CORRECTION après un échec de vérification. Au-delà, on s'arrête
|
||||
/// et on dit ce qui manque : l'utilisateur reprend la main.
|
||||
pub const CODE_MAX_FIX_TURNS: usize = 2;
|
||||
/// Relances du builder qui a rendu la main avec des critères encore ouverts,
|
||||
/// avant de vérifier quand même.
|
||||
pub const CODE_MAX_CONTINUE: usize = 2;
|
||||
|
||||
/// Un critère d'acceptation (`Criterion`).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
|
||||
pub struct Criterion {
|
||||
#[serde(default)]
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
/// pending | completed (le builder dit « fait ») | passed | failed.
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// `critList` : les critères de la discussion (vide si absents ou illisibles,
|
||||
/// mémoire verrouillée comprise).
|
||||
pub async fn crit_list(store: &Store, conv_id: &str) -> Vec<Criterion> {
|
||||
match store.get_open(BK_CHAT, &format!("crit:{conv_id}")).await {
|
||||
Ok(Some(b)) if !b.is_empty() => serde_json::from_slice(&b).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `critAllPassed` : contrat rempli. Une liste vide ne compte pas.
|
||||
pub fn crit_all_passed(list: &[Criterion]) -> bool {
|
||||
!list.is_empty() && list.iter().all(|c| c.status == "passed")
|
||||
}
|
||||
|
||||
/// `critPending` : critères pas encore `passed`.
|
||||
pub fn crit_pending(list: &[Criterion]) -> usize {
|
||||
list.iter().filter(|c| c.status != "passed").count()
|
||||
}
|
||||
|
||||
/// `critOpen` : pas encore déclarés faits par le builder.
|
||||
pub fn crit_open(list: &[Criterion]) -> Vec<Criterion> {
|
||||
list.iter()
|
||||
.filter(|c| matches!(c.status.as_str(), "pending" | "failed" | ""))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `critRender` : la liste formatée pour un prompt.
|
||||
pub fn crit_render(list: &[Criterion]) -> String {
|
||||
list.iter()
|
||||
.map(|c| {
|
||||
let mark = match c.status.as_str() {
|
||||
"passed" => "[✓]",
|
||||
"failed" => "[✗]",
|
||||
"completed" => "[~]", // fait selon le builder, pas encore vérifié
|
||||
_ => "[ ]",
|
||||
};
|
||||
let mut l = format!("{mark} #{} {}", c.id, c.text);
|
||||
if !c.note.is_empty() {
|
||||
l.push_str(&format!(" — {}", c.note));
|
||||
}
|
||||
l
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// `lastUserText` : la tâche — le dernier message utilisateur, hors rappels
|
||||
/// de Loki et sans mise à jour du projet en tête.
|
||||
pub fn last_user_text(msgs: &[Message]) -> String {
|
||||
for m in msgs.iter().rev() {
|
||||
if m.role != "user" || is_loki_injected(m) {
|
||||
continue;
|
||||
}
|
||||
let m = without_ctx_update(m).unwrap_or_else(|| m.clone());
|
||||
if let Some(Value::String(s)) = &m.content {
|
||||
return s.clone();
|
||||
}
|
||||
}
|
||||
"(tâche non retrouvée — vérifie d'après le diff)".into()
|
||||
}
|
||||
|
||||
/// Ce que les passes du contrat partagent avec le tour qui les lance.
|
||||
pub(super) struct PassEnv<'a> {
|
||||
pub env: &'a TurnEnv,
|
||||
pub turn_caps: &'a Value,
|
||||
pub temperature: f64,
|
||||
pub reasoning: &'a TurnReasoning,
|
||||
pub ccaps: CompactCaps,
|
||||
pub conv_id: &'a str,
|
||||
}
|
||||
|
||||
/// L'arrêt du tour et le bail du moteur, tenus pendant les passes.
|
||||
pub(super) struct PassRun<'a> {
|
||||
pub lease: &'a mut EngineLease,
|
||||
pub cancel: &'a Cancel,
|
||||
pub cancel_tx: &'a Arc<watch::Sender<bool>>,
|
||||
}
|
||||
|
||||
/// Ce que rend une passe.
|
||||
#[derive(Default)]
|
||||
struct PassOut {
|
||||
extra: Vec<Message>,
|
||||
content: String,
|
||||
new_base: Option<Vec<Message>>,
|
||||
echo: Option<ReasoningEcho>,
|
||||
saw_usage: bool,
|
||||
asked: bool,
|
||||
}
|
||||
|
||||
fn cap_bool(caps: &Value, k: &str) -> bool {
|
||||
caps.get(k).and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
impl ChatService {
|
||||
/// `codeVerifyLoop` : rien hors mode Code, sans contrat, pour le
|
||||
/// vérificateur ou le planificateur, ni après une question à
|
||||
/// l'utilisateur (sa réponse passe avant).
|
||||
pub(super) async fn code_verify_loop(
|
||||
self: &Arc<Self>,
|
||||
pe: &PassEnv<'_>,
|
||||
epoch: i64,
|
||||
asked: bool,
|
||||
run: &mut PassRun<'_>,
|
||||
) {
|
||||
let role = pe
|
||||
.turn_caps
|
||||
.get("Role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
if !cap_bool(pe.turn_caps, "Code")
|
||||
|| role == "verifier"
|
||||
|| run.cancel.is_cancelled()
|
||||
|| asked
|
||||
{
|
||||
return;
|
||||
}
|
||||
let store = &self.shared.store;
|
||||
let mut list = crit_list(store, pe.conv_id).await;
|
||||
if list.is_empty() || crit_all_passed(&list) || role == "planner" {
|
||||
return; // planification : rien n'a encore été construit
|
||||
}
|
||||
for _ in 0..CODE_MAX_CONTINUE {
|
||||
let open = crit_open(&list);
|
||||
if open.is_empty() {
|
||||
break;
|
||||
}
|
||||
let msg = format!(
|
||||
"Not done yet — these acceptance criteria are still open:\n{}\n\n\
|
||||
Continue working on them. Once one is done and checked, mark it completed \
|
||||
(criteria action=set, status=completed). If you need the user's decision, use ask.",
|
||||
crit_render(&open)
|
||||
);
|
||||
if self.builder_pass(pe, epoch, msg, run).await || run.cancel.is_cancelled() {
|
||||
return; // question posée à l'utilisateur, ou arrêt
|
||||
}
|
||||
list = crit_list(store, pe.conv_id).await;
|
||||
if crit_all_passed(&list) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let conv = self.sessions.conv().clone();
|
||||
let mut fix = 0;
|
||||
loop {
|
||||
if run.cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.verify_pass(pe, epoch, run).await;
|
||||
list = crit_list(store, pe.conv_id).await;
|
||||
if crit_all_passed(&list) {
|
||||
conv.append_delta(epoch, obj("verify_done", "passed"));
|
||||
return;
|
||||
}
|
||||
if fix >= CODE_MAX_FIX_TURNS {
|
||||
conv.append_delta(epoch, obj("verify_done", "gave_up"));
|
||||
// Le fil doit dire où on en est.
|
||||
conv.append_delta(
|
||||
epoch,
|
||||
obj(
|
||||
"content",
|
||||
format!(
|
||||
"\n\n_(vérification : {} critère(s) non satisfaits après {CODE_MAX_FIX_TURNS} corrections — voir le panneau des critères)_",
|
||||
crit_pending(&list)
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let failed: Vec<Criterion> = list
|
||||
.iter()
|
||||
.filter(|c| c.status != "passed")
|
||||
.cloned()
|
||||
.collect();
|
||||
let msg = format!(
|
||||
"Verification failed on these criteria:\n{}\n\n\
|
||||
Fix the code so they pass. Address the notes precisely; do not touch what already passed. \
|
||||
Mark each fixed criterion completed again.",
|
||||
crit_render(&failed)
|
||||
);
|
||||
if self.builder_pass(pe, epoch, msg, run).await {
|
||||
return; // le builder attend une réponse de l'utilisateur
|
||||
}
|
||||
fix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `runVerifyPass` : la vérification sur un contexte ISOLÉ — ni rangée dans
|
||||
/// l'historique, ni compactée avec lui ; seuls ses deltas d'affichage
|
||||
/// sortent.
|
||||
async fn verify_pass(self: &Arc<Self>, pe: &PassEnv<'_>, epoch: i64, run: &mut PassRun<'_>) {
|
||||
let conv = self.sessions.conv().clone();
|
||||
conv.append_delta(epoch, obj("role", "verifier"));
|
||||
let list = crit_list(&self.shared.store, pe.conv_id).await;
|
||||
let task = conv.with_state(|st| last_user_text(&st.messages));
|
||||
let user = format!(
|
||||
"Task:\n{task}\n\nAcceptance criteria:\n{}\n\n\
|
||||
Verify each non-passed criterion now (git_diff first), and record every verdict with the criteria tool.",
|
||||
crit_render(&list)
|
||||
);
|
||||
let view = self
|
||||
.shared
|
||||
.store
|
||||
.internal_post(
|
||||
"/internal/v1/turn/verifier",
|
||||
&json!({ "caps": pe.turn_caps }),
|
||||
)
|
||||
.await;
|
||||
match view {
|
||||
Ok((s, v)) if s.as_u16() == 200 => {
|
||||
let tools: Vec<Tool> =
|
||||
serde_json::from_value(v["tools"].clone()).unwrap_or_default();
|
||||
let msgs = vec![
|
||||
Message {
|
||||
role: "system".into(),
|
||||
content: Some(v["system"].clone()),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: "user".into(),
|
||||
content: Some(Value::String(user)),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
self.run_pass(pe, epoch, msgs, tools, v["caps"].clone(), true, &[], run)
|
||||
.await;
|
||||
}
|
||||
other => {
|
||||
let e = match other {
|
||||
Ok((_, v)) => v["error"]
|
||||
.as_str()
|
||||
.unwrap_or("vue du vérificateur refusée")
|
||||
.to_string(),
|
||||
Err(e) => e,
|
||||
};
|
||||
conv.append_delta(epoch, obj("error", e));
|
||||
}
|
||||
}
|
||||
conv.append_delta(epoch, obj("role", ""));
|
||||
}
|
||||
|
||||
/// `runBuilderTurn` : le builder relancé sur l'historique normal avec une
|
||||
/// consigne ; sa trace est rangée comme un tour ordinaire. `true` : il a
|
||||
/// posé une question à l'utilisateur.
|
||||
async fn builder_pass(
|
||||
self: &Arc<Self>,
|
||||
pe: &PassEnv<'_>,
|
||||
epoch: i64,
|
||||
instruction: String,
|
||||
run: &mut PassRun<'_>,
|
||||
) -> bool {
|
||||
let conv = self.sessions.conv().clone();
|
||||
conv.append_delta(epoch, obj("role", "builder"));
|
||||
let asked = self.builder_pass_inner(pe, epoch, instruction, run).await;
|
||||
conv.append_delta(epoch, obj("role", ""));
|
||||
asked
|
||||
}
|
||||
|
||||
async fn builder_pass_inner(
|
||||
self: &Arc<Self>,
|
||||
pe: &PassEnv<'_>,
|
||||
epoch: i64,
|
||||
instruction: String,
|
||||
run: &mut PassRun<'_>,
|
||||
) -> bool {
|
||||
let conv = self.sessions.conv().clone();
|
||||
let fix = Message {
|
||||
role: "user".into(),
|
||||
content: Some(Value::String(instruction)),
|
||||
..Default::default()
|
||||
};
|
||||
let Some(msgs) = conv.with_state_if(epoch, |st| {
|
||||
st.messages.push(fix);
|
||||
st.messages.clone()
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
// Même contexte que le tour de build : sinon le début du prompt change
|
||||
// et tout le cache est à refaire.
|
||||
let (head, prefix, tools) = match self.turn_view(pe.turn_caps).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
conv.append_delta(epoch, obj("error", e));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let hist = strip_context_updates(msgs);
|
||||
let mut sent = head;
|
||||
sent.extend(hist.iter().cloned());
|
||||
let sent = inject_prefix(sent, &prefix);
|
||||
let out = self
|
||||
.run_pass(
|
||||
pe,
|
||||
epoch,
|
||||
sent,
|
||||
tools,
|
||||
pe.turn_caps.clone(),
|
||||
false,
|
||||
&hist,
|
||||
run,
|
||||
)
|
||||
.await;
|
||||
conv.with_state_if(epoch, |st| {
|
||||
if let Some(base) = out.new_base {
|
||||
st.messages = base;
|
||||
}
|
||||
st.messages.extend(out.extra);
|
||||
if !out.content.trim().is_empty() {
|
||||
let a = Message {
|
||||
role: "assistant".into(),
|
||||
content: Some(Value::String(out.content.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
st.messages.push(with_echo(a, out.echo.as_ref()));
|
||||
}
|
||||
st.messages = drop_stray_nudges(std::mem::take(&mut st.messages));
|
||||
if out.saw_usage {
|
||||
st.ctx_used_len = st.messages.len();
|
||||
}
|
||||
});
|
||||
// En plein tour : l'étape suivante part juste après, l'écriture
|
||||
// ne doit pas l'attendre.
|
||||
self.sessions.persist_async().await;
|
||||
out.asked
|
||||
}
|
||||
|
||||
/// Une passe : le tour tourne, ses événements deviennent des deltas
|
||||
/// (`forwardStream`). `isolated` : trace à part (vérificateur) — ses
|
||||
/// comptes ne disent rien du contexte de la discussion, et sa compaction
|
||||
/// éventuelle n'est pas celle de la discussion.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_pass(
|
||||
self: &Arc<Self>,
|
||||
pe: &PassEnv<'_>,
|
||||
epoch: i64,
|
||||
sent: Vec<Message>,
|
||||
tools: Vec<Tool>,
|
||||
caps: Value,
|
||||
isolated: bool,
|
||||
base: &[Message],
|
||||
run: &mut PassRun<'_>,
|
||||
) -> PassOut {
|
||||
let conv = self.sessions.conv().clone();
|
||||
let env = pe.env;
|
||||
let local = !env.ep.external;
|
||||
let tc = TurnConfig {
|
||||
cfg: env.cfg.clone(),
|
||||
ep: env.ep.clone(),
|
||||
tools: tools.clone(),
|
||||
temperature: pe.temperature,
|
||||
code: cap_bool(&caps, "Code"),
|
||||
reasoning: pe.reasoning.clone(),
|
||||
echo: env.echo.clone(),
|
||||
compact: env.ccfg.clone(),
|
||||
patterns: self.shared.patterns.clone(),
|
||||
};
|
||||
let host = GoTurnHost {
|
||||
shared: self.shared.clone(),
|
||||
conv_id: pe.conv_id.to_string(),
|
||||
caps,
|
||||
cfg: env.cfg.clone(),
|
||||
port: env.port,
|
||||
summarizer: env.summarizer.clone(),
|
||||
compact: TurnCompact {
|
||||
cfg: env.ccfg.clone(),
|
||||
caps: pe.ccaps,
|
||||
criteria: String::new(),
|
||||
main_conv: pe.conv_id.to_string(),
|
||||
tools,
|
||||
effort: pe.reasoning.effort.clone(),
|
||||
kwargs: pe.reasoning.kwargs.clone().unwrap_or_default(),
|
||||
},
|
||||
// Les messages en file attendent la fin du tour (runChatTools sans
|
||||
// injection, comme le Go).
|
||||
queued: Box::new(Vec::new),
|
||||
};
|
||||
let mut out = PassOut::default();
|
||||
let mut cb = |ev: StreamEvent| -> bool {
|
||||
match ev {
|
||||
StreamEvent::NewHistory(h) => {
|
||||
if !isolated {
|
||||
out.new_base = Some(history_from_view(&h, base));
|
||||
}
|
||||
}
|
||||
StreamEvent::Echo(e) => {
|
||||
if !isolated {
|
||||
out.echo = Some(e);
|
||||
}
|
||||
}
|
||||
StreamEvent::Compacting(b) => {
|
||||
if !isolated {
|
||||
conv.append_delta(epoch, obj("compacting", b));
|
||||
}
|
||||
}
|
||||
StreamEvent::Err(e) => {
|
||||
conv.append_delta(epoch, obj("error", e));
|
||||
}
|
||||
StreamEvent::ToolUsed(t) => {
|
||||
out.content.clear(); // déjà dans le message tool_calls
|
||||
conv.append_delta(epoch, tool_used_delta(&t));
|
||||
}
|
||||
StreamEvent::Ask(a) => {
|
||||
out.asked = true;
|
||||
conv.append_delta(
|
||||
epoch,
|
||||
obj("ask", serde_json::to_value(&a).unwrap_or_default()),
|
||||
);
|
||||
}
|
||||
StreamEvent::Stats(s) => {
|
||||
let stats = serde_json::to_value(&s).unwrap_or_default();
|
||||
if isolated {
|
||||
let mut d = obj("stats", stats);
|
||||
d.insert("ctx_isolated".into(), Value::Bool(true));
|
||||
conv.append_delta(epoch, d);
|
||||
return true;
|
||||
}
|
||||
if s.prompt_tokens_total > 0 {
|
||||
out.saw_usage = true;
|
||||
}
|
||||
conv.with_state_if(epoch, |st| {
|
||||
if s.prompt_tokens_total > 0 {
|
||||
st.ctx_used = s.ctx_after();
|
||||
}
|
||||
if local {
|
||||
st.gen_peak = st.gen_peak.max(s.gen_tokens);
|
||||
}
|
||||
});
|
||||
conv.append_delta(epoch, obj("stats", stats));
|
||||
}
|
||||
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() => {
|
||||
out.content.push_str(&c);
|
||||
conv.append_delta(epoch, obj("content", c));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
};
|
||||
let res = {
|
||||
let turn = run_turn(&host, &tc, sent, run.cancel, &mut cb);
|
||||
tokio::pin!(turn);
|
||||
tokio::select! {
|
||||
r = &mut turn => r,
|
||||
sig = run.lease.interrupted() => {
|
||||
if sig == LeaseSignal::Lost {
|
||||
eprintln!("[chat] bail du moteur perdu pendant la vérification : arrêt");
|
||||
}
|
||||
let _ = run.cancel_tx.send(true);
|
||||
turn.await
|
||||
}
|
||||
}
|
||||
};
|
||||
out.extra = res.extra;
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(id: i64, status: &str) -> Criterion {
|
||||
Criterion {
|
||||
id,
|
||||
text: format!("critère {id}"),
|
||||
status: status.into(),
|
||||
note: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrat() {
|
||||
let list = vec![c(1, "passed"), c(2, "completed"), c(3, "failed"), c(4, "")];
|
||||
assert!(!crit_all_passed(&list) && !crit_all_passed(&[]));
|
||||
assert!(crit_all_passed(&[c(1, "passed")]));
|
||||
assert_eq!(crit_pending(&list), 3);
|
||||
assert_eq!(
|
||||
crit_open(&list).iter().map(|c| c.id).collect::<Vec<_>>(),
|
||||
[3, 4]
|
||||
);
|
||||
let mut noted = c(5, "failed");
|
||||
noted.note = "le test échoue".into();
|
||||
assert_eq!(
|
||||
crit_render(&[c(1, "passed"), c(2, "completed"), noted, c(4, "pending")]),
|
||||
"[✓] #1 critère 1\n[~] #2 critère 2\n[✗] #5 critère 5 — le test échoue\n[ ] #4 critère 4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tache_retrouvee() {
|
||||
let u = |s: &str| Message {
|
||||
role: "user".into(),
|
||||
content: Some(Value::String(s.into())),
|
||||
..Default::default()
|
||||
};
|
||||
let a = Message {
|
||||
role: "assistant".into(),
|
||||
content: Some(Value::String("ok".into())),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
last_user_text(&[u("fais le jeu"), a.clone()]),
|
||||
"fais le jeu"
|
||||
);
|
||||
assert_eq!(
|
||||
last_user_text(&[a]),
|
||||
"(tâche non retrouvée — vérifie d'après le diff)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Routes du chat servies par Rust quand la conversation est tenue par la
|
||||
//! façade (`LOKI_CHAT=rust`) — port des handlers de `web_chat.go`.
|
||||
//!
|
||||
//! - `/api/chat` : flux d'abonnement SSE (rejeu puis direct, battement de
|
||||
//! cœur toutes les 4 s, trames regroupées) ;
|
||||
//! - `/api/chat/send|stop|reset|state|compact|tool-result` ;
|
||||
//! - `/api/conversations{,/new,/switch,/rename,/delete}`.
|
||||
//!
|
||||
//! Le reste (mode Code et critères, pièces jointes, fichiers, images,
|
||||
//! export) reste au Go, qui suit la discussion active et pousse ses propres
|
||||
//! événements d'affichage dans le journal tenu ici ([`serve_internal`]). Le
|
||||
//! trafic de chat qui arrive au Go sans passer par la façade (tunnel du
|
||||
//! relais) lui est remis, authentifié par le jeton interne.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full, Limited, StreamBody};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use hyper::header::{self, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::api::{Ctx, query_param};
|
||||
use crate::auth::json as send_json;
|
||||
use crate::chat::generate::ChatService;
|
||||
use crate::chat::journal::{SSE_HEARTBEAT, SseBatcher, sse_data_frame};
|
||||
use crate::chat::sessions::SessionError;
|
||||
use crate::proxy::Body;
|
||||
|
||||
/// Corps de requête lu au plus (un message collé peut être long).
|
||||
const MAX_BODY: usize = 32 << 20;
|
||||
/// Battement de cœur du flux (`sseHeartbeat`).
|
||||
const HEARTBEAT: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Cette route est-elle servie par la conversation tenue ici ?
|
||||
pub fn handles(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/api/chat"
|
||||
| "/api/chat/send"
|
||||
| "/api/chat/stop"
|
||||
| "/api/chat/reset"
|
||||
| "/api/chat/state"
|
||||
| "/api/chat/compact"
|
||||
| "/api/chat/tool-result"
|
||||
| "/api/conversations"
|
||||
| "/api/conversations/new"
|
||||
| "/api/conversations/switch"
|
||||
| "/api/conversations/rename"
|
||||
| "/api/conversations/delete"
|
||||
)
|
||||
}
|
||||
|
||||
/// API interne de la façade, pour le Go : un événement d'affichage né chez
|
||||
/// lui (bascule du mode Code, critères) à journaliser ici, le rechargement de
|
||||
/// la discussion après le déverrouillage de la mémoire, le changement de
|
||||
/// projet et les discussions d'un projet supprimé.
|
||||
pub fn is_internal(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/internal/facade/v1/delta"
|
||||
| "/internal/facade/v1/reload"
|
||||
| "/internal/facade/v1/project/switch"
|
||||
| "/internal/facade/v1/project/drop"
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn serve_internal(chat: &Arc<ChatService>, req: Request<Incoming>) -> Response<Body> {
|
||||
let path = req.uri().path().to_string();
|
||||
if req.method() != Method::POST {
|
||||
return send_json(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
&json!({ "error": "POST attendu" }),
|
||||
);
|
||||
}
|
||||
match path.as_str() {
|
||||
"/internal/facade/v1/delta" => match read_json(req.into_body()).await {
|
||||
Ok(Value::Object(d)) if !d.is_empty() => {
|
||||
chat.push_delta(d);
|
||||
Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Full::new(Bytes::new()).map_err(|n| match n {}).boxed())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => send_json(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&json!({ "error": "événement illisible" }),
|
||||
),
|
||||
},
|
||||
"/internal/facade/v1/project/switch" | "/internal/facade/v1/project/drop" => {
|
||||
let v = read_json(req.into_body()).await.unwrap_or_default();
|
||||
let slug = str_of(&v, "slug");
|
||||
let res = if path.ends_with("switch") {
|
||||
chat.project_switch(slug)
|
||||
.await
|
||||
.map(|()| json!({ "ok": true }))
|
||||
} else {
|
||||
chat.drop_project(slug)
|
||||
.await
|
||||
.map(|n| json!({ "ok": true, "dropped": n }))
|
||||
};
|
||||
match res {
|
||||
Ok(v) => send_json(StatusCode::OK, &v),
|
||||
Err(r) => refused(r.status, &r.error),
|
||||
}
|
||||
}
|
||||
_ => match chat.sessions.reload_if_empty().await {
|
||||
Ok(reloaded) => send_json(StatusCode::OK, &json!({ "reloaded": reloaded })),
|
||||
Err(e) => send_json(StatusCode::SERVICE_UNAVAILABLE, &json!({ "error": e })),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Corps JSON de la requête ; vide → `{}` ; illisible → `Err(message)`.
|
||||
async fn read_json(body: Incoming) -> Result<Value, String> {
|
||||
let bytes = Limited::new(body, MAX_BODY)
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_bytes();
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Err("EOF".into());
|
||||
}
|
||||
serde_json::from_slice(&bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn ok() -> Response<Body> {
|
||||
send_json(StatusCode::OK, &json!({ "ok": true }))
|
||||
}
|
||||
|
||||
fn refused(status: u16, error: &str) -> Response<Body> {
|
||||
send_json(
|
||||
StatusCode::from_u16(status).unwrap_or(StatusCode::SERVICE_UNAVAILABLE),
|
||||
&json!({ "ok": false, "error": error }),
|
||||
)
|
||||
}
|
||||
|
||||
fn session_error(e: SessionError) -> Response<Body> {
|
||||
send_json(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&json!({ "ok": false, "error": e.to_string() }),
|
||||
)
|
||||
}
|
||||
|
||||
fn str_of<'a>(v: &'a Value, k: &str) -> &'a str {
|
||||
v.get(k).and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
/// `from_go` : relayée par le Go, qui l'a déjà authentifiée.
|
||||
pub async fn serve(
|
||||
ctx: &Ctx,
|
||||
chat: &Arc<ChatService>,
|
||||
req: Request<Incoming>,
|
||||
from_go: bool,
|
||||
) -> Response<Body> {
|
||||
let (parts, body) = req.into_parts();
|
||||
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
|
||||
return *refus;
|
||||
}
|
||||
let path = parts.uri.path().to_string();
|
||||
match path.as_str() {
|
||||
"/api/chat" => match read_json(body).await {
|
||||
Ok(v) => subscribe(chat, &v),
|
||||
Err(e) => plain(StatusCode::BAD_REQUEST, &e),
|
||||
},
|
||||
"/api/chat/send" => {
|
||||
let v = match read_json(body).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return refused(400, &e),
|
||||
};
|
||||
match chat.send(v).await {
|
||||
Ok(s) if s.dup => send_json(StatusCode::OK, &json!({ "ok": true, "dup": true })),
|
||||
Ok(s) => send_json(StatusCode::OK, &json!({ "ok": true, "queued": s.queued })),
|
||||
Err(r) => refused(r.status, &r.error),
|
||||
}
|
||||
}
|
||||
"/api/chat/stop" => {
|
||||
chat.stop().await;
|
||||
ok()
|
||||
}
|
||||
"/api/chat/reset" => {
|
||||
chat.reset().await;
|
||||
ok()
|
||||
}
|
||||
"/api/chat/state" => send_json(
|
||||
StatusCode::OK,
|
||||
&Value::Object(chat.sessions.conv().state_json()),
|
||||
),
|
||||
"/api/chat/compact" => match chat.compact_now().await {
|
||||
Ok(()) => ok(),
|
||||
Err(r) => refused(r.status, &r.error),
|
||||
},
|
||||
"/api/chat/tool-result" => {
|
||||
let id = query_param(&parts.uri, "id").unwrap_or_default();
|
||||
let id = id.trim();
|
||||
if id.is_empty() {
|
||||
return send_json(StatusCode::BAD_REQUEST, &json!({ "error": "id manquant" }));
|
||||
}
|
||||
match chat.tool_result(id).await {
|
||||
Some(s) => send_json(StatusCode::OK, &json!({ "result": s })),
|
||||
None => send_json(
|
||||
StatusCode::NOT_FOUND,
|
||||
&json!({ "error": "résultat non disponible" }),
|
||||
),
|
||||
}
|
||||
}
|
||||
"/api/conversations" => match chat.sessions.list().await {
|
||||
Ok((list, active)) => send_json(
|
||||
StatusCode::OK,
|
||||
&json!({ "conversations": list, "active": active, "busy": chat.sessions.conv().is_generating() }),
|
||||
),
|
||||
Err(e) => send_json(StatusCode::SERVICE_UNAVAILABLE, &json!({ "error": e })),
|
||||
},
|
||||
"/api/conversations/new" => match chat.sessions.new_conv().await {
|
||||
Ok(id) => send_json(StatusCode::OK, &json!({ "ok": true, "id": id })),
|
||||
Err(e) => session_error(e),
|
||||
},
|
||||
// Corps illisible : champs vides, comme le `_ = Decode` du Go.
|
||||
"/api/conversations/switch" => {
|
||||
let v = read_json(body).await.unwrap_or_default();
|
||||
match chat.sessions.switch(str_of(&v, "id")).await {
|
||||
Ok(()) => ok(),
|
||||
Err(e) => session_error(e),
|
||||
}
|
||||
}
|
||||
"/api/conversations/rename" => {
|
||||
let v = read_json(body).await.unwrap_or_default();
|
||||
match chat
|
||||
.sessions
|
||||
.rename(str_of(&v, "id"), str_of(&v, "title"))
|
||||
.await
|
||||
{
|
||||
Ok(()) => ok(),
|
||||
Err(e) => session_error(e),
|
||||
}
|
||||
}
|
||||
"/api/conversations/delete" => {
|
||||
let v = read_json(body).await.unwrap_or_default();
|
||||
match chat.sessions.delete(str_of(&v, "id")).await {
|
||||
Ok(()) => ok(),
|
||||
Err(e) => session_error(e),
|
||||
}
|
||||
}
|
||||
_ => plain(StatusCode::NOT_FOUND, "404 page not found"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleChat` : le flux d'abonnement. `from` : dernier Seq vu ; `tail` :
|
||||
/// échanges à rejouer (absent : ancien client, tout) ; `conv_id` : discussion
|
||||
/// affichée.
|
||||
fn subscribe(chat: &Arc<ChatService>, v: &Value) -> Response<Body> {
|
||||
let from = v.get("from").and_then(Value::as_i64).unwrap_or(0);
|
||||
let tail = v.get("tail").and_then(Value::as_i64).unwrap_or(-1);
|
||||
let mut sub = chat
|
||||
.sessions
|
||||
.conv()
|
||||
.subscribe(from, tail, str_of(v, "conv_id"));
|
||||
let (tx, rx) = mpsc::channel::<Bytes>(8);
|
||||
tokio::spawn(async move {
|
||||
let mut beat = tokio::time::interval(HEARTBEAT);
|
||||
beat.tick().await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
evs = sub.next() => {
|
||||
let mut batch = SseBatcher::new();
|
||||
for e in &evs {
|
||||
if let Some(chunk) = batch.queue(&sse_data_frame(e))
|
||||
&& tx.send(Bytes::from(chunk)).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(chunk) = batch.flush()
|
||||
&& tx.send(Bytes::from(chunk)).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = beat.tick() => {
|
||||
if tx.send(Bytes::from_static(SSE_HEARTBEAT)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = tx.closed() => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
|
||||
rx.recv()
|
||||
.await
|
||||
.map(|b| (Ok::<_, hyper::Error>(Frame::data(b)), rx))
|
||||
});
|
||||
let mut r = Response::new(BodyExt::boxed(StreamBody::new(stream)));
|
||||
let h = r.headers_mut();
|
||||
h.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
h.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-transform"),
|
||||
);
|
||||
h.insert("x-accel-buffering", HeaderValue::from_static("no"));
|
||||
r
|
||||
}
|
||||
|
||||
fn plain(status: StatusCode, msg: &str) -> Response<Body> {
|
||||
let mut r = Response::new(
|
||||
Full::new(Bytes::from(format!("{msg}\n")))
|
||||
.map_err(|n| match n {})
|
||||
.boxed(),
|
||||
);
|
||||
*r.status_mut() = status;
|
||||
r.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn routes() {
|
||||
assert!(handles("/api/chat") && handles("/api/conversations/delete"));
|
||||
assert!(
|
||||
!handles("/api/chat/upload")
|
||||
&& !handles("/api/chat/export")
|
||||
&& !handles("/api/chat/mode")
|
||||
);
|
||||
assert!(is_internal("/internal/facade/v1/delta") && !is_internal("/internal/v1/auth"));
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,14 @@
|
||||
//! - `ui` : UI web statique embarquée (lot 2) ;
|
||||
//! - `auth` : authentification de l'API de pilotage (lot 2) ;
|
||||
//! - `oai` : surface compatible OpenAI `/v1` relayée au moteur (lot 5) ;
|
||||
//! - `chat` : cœur chat (lot 6, bibliothèque en cours de portage) ;
|
||||
//! - `chat` : cœur chat (lot 6) ; `chatapi` : ses routes, servies quand la
|
||||
//! conversation est tenue par la façade (`LOKI_CHAT=rust`) ;
|
||||
//! - `update` : mise à jour des binaires au démarrage du conteneur.
|
||||
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
pub mod chat;
|
||||
pub mod chatapi;
|
||||
pub mod child;
|
||||
pub mod engine;
|
||||
pub mod engstatus;
|
||||
@@ -22,5 +24,6 @@ pub mod presets;
|
||||
pub mod proxy;
|
||||
pub mod store;
|
||||
pub mod sysinfo;
|
||||
pub mod tools;
|
||||
pub mod ui;
|
||||
pub mod update;
|
||||
@@ -114,12 +114,45 @@ fn serve() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let sup = child::Supervisor::spawn(
|
||||
go_bin,
|
||||
go_port,
|
||||
vec![(auth::INTERNAL_ENV.to_string(), token.clone())],
|
||||
);
|
||||
// LOKI_CHAT=rust : la conversation est tenue par la façade. Le Go le
|
||||
// sait (il remet alors à la façade le trafic de chat du tunnel) et
|
||||
// connaît son adresse locale.
|
||||
let chat_rust = chat_held_by_facade();
|
||||
let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())];
|
||||
if chat_rust {
|
||||
env.push(("LOKI_CHAT".into(), "rust".into()));
|
||||
env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}")));
|
||||
}
|
||||
let sup = child::Supervisor::spawn(go_bin, go_port, env);
|
||||
wait_for(backend, Duration::from_secs(30)).await;
|
||||
let engine_paths = engine::Paths::from_env();
|
||||
// Le Go peut garder la conversation malgré LOKI_CHAT=rust (réglage que
|
||||
// la façade ne sait pas encore honorer) : sa décision fait foi.
|
||||
let chat_rust = chat_rust
|
||||
&& match go_keeps_chat(backend, &token).await {
|
||||
Ok(None) => true,
|
||||
Ok(Some(why)) => {
|
||||
eprintln!("[loki-rs] conversation tenue par le Go ({why})");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[loki-rs] conversation : {e}");
|
||||
sup.shutdown(Duration::from_secs(10)).await;
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let chat = if chat_rust {
|
||||
match start_chat(backend, &token, &engine_paths.home).await {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("[loki-rs] conversation : {e}");
|
||||
sup.shutdown(Duration::from_secs(10)).await;
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], web_port));
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
@@ -135,7 +168,8 @@ fn serve() -> ExitCode {
|
||||
backend,
|
||||
internal_token: token,
|
||||
host_env: auth::HostEnv::from_env(),
|
||||
engine: engine::Paths::from_env(),
|
||||
engine: engine_paths,
|
||||
chat,
|
||||
};
|
||||
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
|
||||
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
|
||||
@@ -150,6 +184,47 @@ fn serve() -> ExitCode {
|
||||
})
|
||||
}
|
||||
|
||||
/// `LOKI_CHAT=rust` : conversation tenue par la façade (Go par défaut).
|
||||
fn chat_held_by_facade() -> bool {
|
||||
std::env::var("LOKI_CHAT").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
|
||||
}
|
||||
|
||||
/// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer)
|
||||
async fn go_keeps_chat(backend: SocketAddr, token: &str) -> Result<Option<String>, String> {
|
||||
let store = loki_rs::store::Store::new(backend, token.to_string());
|
||||
let mut last = String::new();
|
||||
for _ in 0..5 {
|
||||
match loki_rs::chat::generate::go_keeps_chat(&store).await {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) => last = e,
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// Charge la discussion active et prépare le service des tours.
|
||||
async fn start_chat(
|
||||
backend: SocketAddr,
|
||||
token: &str,
|
||||
home: &std::path::Path,
|
||||
) -> Result<std::sync::Arc<loki_rs::chat::generate::ChatService>, String> {
|
||||
use loki_rs::chat::{conv::Conversation, generate::ChatService, host::ChatShared, sessions};
|
||||
let store = loki_rs::store::Store::new(backend, token.to_string());
|
||||
let s = sessions::Sessions::new(
|
||||
store.clone(),
|
||||
Conversation::new(),
|
||||
sessions::GoSessionHooks(store.clone()),
|
||||
);
|
||||
s.load().await?;
|
||||
let shared = ChatShared::for_sessions(&s, store, home.join("chatimg"));
|
||||
eprintln!("[loki-rs] conversation tenue par la façade (LOKI_CHAT=rust)");
|
||||
Ok(ChatService::new(
|
||||
std::sync::Arc::new(s),
|
||||
std::sync::Arc::new(shared),
|
||||
))
|
||||
}
|
||||
|
||||
fn internal_token() -> Result<String, getrandom::Error> {
|
||||
let mut b = [0u8; 32];
|
||||
getrandom::fill(&mut b)?;
|
||||
|
||||
@@ -29,6 +29,7 @@ use tokio::net::TcpListener;
|
||||
|
||||
use crate::api;
|
||||
use crate::auth::{HostEnv, INTERNAL_HEADER, InternalClient};
|
||||
use crate::chatapi;
|
||||
use crate::engine;
|
||||
use crate::oai;
|
||||
use crate::store::Store;
|
||||
@@ -59,6 +60,9 @@ pub struct Config {
|
||||
pub host_env: HostEnv,
|
||||
/// `$LOKI_HOME` et service moteur, pour les routes portées.
|
||||
pub engine: engine::Paths,
|
||||
/// Conversation tenue par la façade (`LOKI_CHAT=rust`) : ses routes sont
|
||||
/// servies ici ; `None` : relayées au Go.
|
||||
pub chat: Option<Arc<crate::chat::generate::ChatService>>,
|
||||
}
|
||||
|
||||
struct Proxy {
|
||||
@@ -82,9 +86,11 @@ pub async fn serve(
|
||||
backend: cfg.backend,
|
||||
ctx: api::Ctx {
|
||||
store: Store::new(cfg.backend, cfg.internal_token.clone()),
|
||||
internal: InternalClient::new(cfg.backend, cfg.internal_token),
|
||||
internal: InternalClient::new(cfg.backend, cfg.internal_token.clone()),
|
||||
host_env: cfg.host_env,
|
||||
engine: cfg.engine,
|
||||
chat: cfg.chat,
|
||||
internal_token: cfg.internal_token.clone(),
|
||||
},
|
||||
});
|
||||
tokio::pin!(shutdown);
|
||||
@@ -122,12 +128,22 @@ pub async fn serve(
|
||||
|
||||
impl Proxy {
|
||||
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
|
||||
// Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la
|
||||
// façade le trafic de chat qui lui arrive sans passer par elle.
|
||||
let from_go = self.ctx.chat.is_some() && self.ctx.from_go(req.headers());
|
||||
// Jamais de confiance accordée sur la foi d'un en-tête du client :
|
||||
// ni « je viens du tunnel », ni « je suis la façade ».
|
||||
req.headers_mut().remove(VIA_TUNNEL);
|
||||
req.headers_mut().remove(INTERNAL_HEADER);
|
||||
|
||||
let path = req.uri().path();
|
||||
// L'API interne de la façade : pour le Go seulement.
|
||||
if from_go
|
||||
&& let Some(chat) = &self.ctx.chat
|
||||
&& chatapi::is_internal(path)
|
||||
{
|
||||
return chatapi::serve_internal(chat, req).await;
|
||||
}
|
||||
// L'API interne du Go n'est jamais joignable de l'extérieur.
|
||||
if path.starts_with("/internal/") {
|
||||
return plain(StatusCode::NOT_FOUND, "404 page not found");
|
||||
@@ -142,6 +158,11 @@ impl Proxy {
|
||||
if api::handles(req.method(), path) {
|
||||
return api::serve(&self.ctx, req).await;
|
||||
}
|
||||
if let Some(chat) = &self.ctx.chat
|
||||
&& chatapi::handles(path)
|
||||
{
|
||||
return chatapi::serve(&self.ctx, chat, req, from_go).await;
|
||||
}
|
||||
self.forward(req).await
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,24 @@ impl Store {
|
||||
serde_json::from_slice(&bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// POST JSON à l'API interne, statut compris (refus métier à distinguer :
|
||||
/// 409, 410…). Corps vide ou illisible → `Value::Null`.
|
||||
pub async fn internal_post(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<(StatusCode, serde_json::Value), String> {
|
||||
let (status, bytes) = self
|
||||
.call(
|
||||
Method::POST,
|
||||
path,
|
||||
serde_json::to_vec(body).unwrap_or_default(),
|
||||
)
|
||||
.await?;
|
||||
let v = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
|
||||
Ok((status, v))
|
||||
}
|
||||
|
||||
/// Scelle un blob avec la clé de la mémoire (gardée par le Go). Clair
|
||||
/// rendu tel quel si le chiffrement est coupé.
|
||||
pub async fn seal(&self, plain: Vec<u8>) -> Result<Vec<u8>, CryptError> {
|
||||
@@ -109,6 +127,31 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// `getStoreBytesErr` : lit et ouvre une valeur scellée. Absente, ou
|
||||
/// illisible (mémoire verrouillée, autre clé) → `Ok(None)` ; `Err`
|
||||
/// seulement si la base n'a pas pu être lue (« rien » et « pas pu lire »
|
||||
/// ne veulent pas dire la même chose).
|
||||
pub async fn get_open(&self, bucket: &str, key: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
match self.get(bucket, key).await? {
|
||||
Some(raw) if !raw.is_empty() => Ok(self.open(raw).await.ok()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// `putStoreBytes` : scelle puis écrit. Mémoire verrouillée : rien n'est
|
||||
/// écrit (jamais en clair).
|
||||
pub async fn put_seal(
|
||||
&self,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
plain: Vec<u8>,
|
||||
) -> Result<(), CryptError> {
|
||||
let sealed = self.seal(plain).await?;
|
||||
self.put(bucket, key, sealed)
|
||||
.await
|
||||
.map_err(CryptError::Other)
|
||||
}
|
||||
|
||||
/// Exécute un appel d'outil par le Go (`/internal/v1/tool/exec`) : même
|
||||
/// fonction que sa boucle de chat. `caps` : objet JSON des capacités du
|
||||
/// tour (champs Go : Agent, Internet, Mem, Code, Role, ComputerUse).
|
||||
@@ -126,6 +169,28 @@ impl Store {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Archive un bloc verbatim pour l'outil recall (`archiveRecallBlock`,
|
||||
/// numéroté par le Go) et rend son id (« r7 »).
|
||||
pub async fn recall_archive(
|
||||
&self,
|
||||
label: &str,
|
||||
role: &str,
|
||||
content: &str,
|
||||
) -> Result<String, String> {
|
||||
let v = self
|
||||
.internal_json(
|
||||
Method::POST,
|
||||
"/internal/v1/recall/archive",
|
||||
Some(&serde_json::json!({ "label": label, "role": role, "content": content })),
|
||||
)
|
||||
.await?;
|
||||
v.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.filter(|i| !i.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("archive recall : réponse sans id {v}"))
|
||||
}
|
||||
|
||||
/// Valeur brute ; `Ok(None)` = absente, `Err` = pas pu lire (à distinguer
|
||||
/// pour les secrets : comme `getBytesErr`).
|
||||
pub async fn get(&self, bucket: &str, key: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Petit diff ligne à ligne pour l'interface — port de `chat_diff.go`.
|
||||
//!
|
||||
//! Quand l'agent modifie un fichier (`edit`) ou en crée un (`write`), l'UI
|
||||
//! affiche les lignes ajoutées (vert) et retirées (rouge). LCS classique sur
|
||||
//! les lignes, avec des garde-fous pour ne jamais transformer un gros
|
||||
//! remplacement en pavé. Les compteurs +N / -N sont calculés AVANT la coupe
|
||||
//! des lignes affichées.
|
||||
|
||||
use crate::chat::types::DiffLine;
|
||||
|
||||
/// Au-delà (partie modifiée), comparaison sans détail (LCS trop lente).
|
||||
const DIFF_MAX_LINES: usize = 400;
|
||||
/// Lignes envoyées à l'UI (le reste est résumé).
|
||||
const DIFF_MAX_SHOWN: usize = 120;
|
||||
/// Lignes identiques gardées autour d'un changement.
|
||||
const DIFF_CONTEXT: usize = 3;
|
||||
const DIFF_OMIT_PREFIX: &str = "…";
|
||||
|
||||
fn line(op: &str, text: &str) -> DiffLine {
|
||||
DiffLine {
|
||||
op: op.into(),
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `lineDiff` : lignes à afficher (bornées), lignes ajoutées, retirées.
|
||||
pub fn line_diff(old_text: &str, new_text: &str) -> (Vec<DiffLine>, i64, i64) {
|
||||
let a = split_lines(old_text);
|
||||
let b = split_lines(new_text);
|
||||
let mut pre = 0;
|
||||
while pre < a.len() && pre < b.len() && a[pre] == b[pre] {
|
||||
pre += 1;
|
||||
}
|
||||
let mut suf = 0;
|
||||
while suf < a.len() - pre && suf < b.len() - pre && a[a.len() - 1 - suf] == b[b.len() - 1 - suf]
|
||||
{
|
||||
suf += 1;
|
||||
}
|
||||
let (ma, mb) = (&a[pre..a.len() - suf], &b[pre..b.len() - suf]);
|
||||
let mid: Vec<DiffLine> = if ma.len() > DIFF_MAX_LINES || mb.len() > DIFF_MAX_LINES {
|
||||
ma.iter()
|
||||
.map(|l| line("-", l))
|
||||
.chain(mb.iter().map(|l| line("+", l)))
|
||||
.collect()
|
||||
} else {
|
||||
lcs_diff(ma, mb)
|
||||
};
|
||||
let added = mid.iter().filter(|d| d.op == "+").count() as i64;
|
||||
let removed = mid.iter().filter(|d| d.op == "-").count() as i64;
|
||||
|
||||
let mut out = Vec::new();
|
||||
if pre > DIFF_CONTEXT {
|
||||
out.push(line(" ", &omitted(pre - DIFF_CONTEXT)));
|
||||
}
|
||||
for l in &a[pre.saturating_sub(DIFF_CONTEXT)..pre] {
|
||||
out.push(line(" ", l));
|
||||
}
|
||||
out.extend(mid);
|
||||
for l in &a[a.len() - suf..a.len() - suf + suf.min(DIFF_CONTEXT)] {
|
||||
out.push(line(" ", l));
|
||||
}
|
||||
if suf > DIFF_CONTEXT {
|
||||
out.push(line(" ", &omitted(suf - DIFF_CONTEXT)));
|
||||
}
|
||||
(cap_lines(out), added, removed)
|
||||
}
|
||||
|
||||
/// Diff exact par plus longue sous-séquence commune.
|
||||
fn lcs_diff(a: &[String], b: &[String]) -> Vec<DiffLine> {
|
||||
let (n, m) = (a.len(), b.len());
|
||||
let mut lcs = vec![vec![0usize; m + 1]; n + 1];
|
||||
for i in (0..n).rev() {
|
||||
for j in (0..m).rev() {
|
||||
lcs[i][j] = if a[i] == b[j] {
|
||||
lcs[i + 1][j + 1] + 1
|
||||
} else {
|
||||
lcs[i + 1][j].max(lcs[i][j + 1])
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < n && j < m {
|
||||
if a[i] == b[j] {
|
||||
out.push(line(" ", &a[i]));
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if lcs[i + 1][j] >= lcs[i][j + 1] {
|
||||
out.push(line("-", &a[i]));
|
||||
i += 1;
|
||||
} else {
|
||||
out.push(line("+", &b[j]));
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
out.extend(a[i..].iter().map(|l| line("-", l)));
|
||||
out.extend(b[j..].iter().map(|l| line("+", l)));
|
||||
out
|
||||
}
|
||||
|
||||
/// `addedDiff` : contenu entièrement nouveau, toutes ses lignes en ajout.
|
||||
pub fn added_diff(text: &str) -> (Vec<DiffLine>, i64) {
|
||||
let ls = split_lines(text);
|
||||
let n = ls.len() as i64;
|
||||
(cap_lines(ls.iter().map(|l| line("+", l)).collect()), n)
|
||||
}
|
||||
|
||||
/// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une
|
||||
/// vide. Les fins CRLF comptent comme LF.
|
||||
fn split_lines(s: &str) -> Vec<String> {
|
||||
let s = s.replace("\r\n", "\n");
|
||||
let s = s.strip_suffix('\n').unwrap_or(&s);
|
||||
if s.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
s.split('\n').map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn omitted(n: usize) -> String {
|
||||
if n == 1 {
|
||||
format!("{DIFF_OMIT_PREFIX}(1 ligne identique)")
|
||||
} else {
|
||||
format!("{DIFF_OMIT_PREFIX}({n} lignes identiques)")
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_lines(mut d: Vec<DiffLine>) -> Vec<DiffLine> {
|
||||
if d.len() <= DIFF_MAX_SHOWN {
|
||||
return d;
|
||||
}
|
||||
let cut = d.len() - DIFF_MAX_SHOWN;
|
||||
d.truncate(DIFF_MAX_SHOWN);
|
||||
d.push(line(
|
||||
" ",
|
||||
&format!("{DIFF_OMIT_PREFIX}({cut} lignes de plus)"),
|
||||
));
|
||||
d
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ops(d: &[DiffLine]) -> Vec<String> {
|
||||
d.iter().map(|l| format!("{}{}", l.op, l.text)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn une_ligne_changee_dans_un_gros_bloc() {
|
||||
let old: String = (1..=20).map(|i| format!("l{i}\n")).collect();
|
||||
let new = old.replace("l10\n", "dix\n");
|
||||
let (d, a, r) = line_diff(&old, &new);
|
||||
assert_eq!((a, r), (1, 1));
|
||||
assert_eq!(
|
||||
ops(&d),
|
||||
[
|
||||
" …(6 lignes identiques)",
|
||||
" l7",
|
||||
" l8",
|
||||
" l9",
|
||||
"-l10",
|
||||
"+dix",
|
||||
" l11",
|
||||
" l12",
|
||||
" l13",
|
||||
" …(7 lignes identiques)"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ajout_et_coupe() {
|
||||
let text: String = (0..130).map(|i| format!("{i}\n")).collect();
|
||||
let (d, n) = added_diff(&text);
|
||||
assert_eq!(n, 130);
|
||||
assert_eq!(d.len(), 121);
|
||||
assert_eq!(d[120].text, "…(10 lignes de plus)");
|
||||
assert_eq!(added_diff("").1, 0);
|
||||
assert_eq!(added_diff("a\r\nb\r\n").1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gros_remplacement_sans_lcs() {
|
||||
let old: String = (0..401).map(|i| format!("a{i}\n")).collect();
|
||||
let (_, a, r) = line_diff(&old, "b\n");
|
||||
assert_eq!((a, r), (1, 401));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
//! Outils fichiers de l'agent — port de `code_tools.go` (read, grep, glob,
|
||||
//! ask) et de `fileWrite` / `fileEdit` (`chat_tools.go`).
|
||||
//!
|
||||
//! Fonctions synchrones (disque) : l'appelant les lance hors du runtime
|
||||
//! (`spawn_blocking`) et se charge de ce qui est partagé avec le Go (suivi
|
||||
//! « lu avant d'écrire », diagnostics LSP — voir `tools::exec_local`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::policy::{
|
||||
code_path_allowed, guard_tool_only_path, refused_path_result, resolve_agent_path, to_slash,
|
||||
};
|
||||
|
||||
/// Lignes par appel (offset/limit pour la suite).
|
||||
pub const READ_MAX_LINES: usize = 1000;
|
||||
pub const READ_DEF_LINES: usize = 400;
|
||||
/// Une ligne minifiée ne doit pas manger le contexte.
|
||||
const READ_MAX_LINE_LEN: usize = 500;
|
||||
const GREP_MAX_HITS: usize = 60;
|
||||
const GLOB_MAX_HITS: usize = 200;
|
||||
|
||||
/// `skipDirs` : dépendances et artefacts, jamais du code à lire.
|
||||
const SKIP_DIRS: [&str; 11] = [
|
||||
".git",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
".next",
|
||||
".cache",
|
||||
];
|
||||
|
||||
fn skip_dir(name: &str) -> bool {
|
||||
SKIP_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
/// Texte d'erreur du système à la manière de Go (`no such file or
|
||||
/// directory`) : le message de l'OS, sans « (os error N) », minuscule en tête
|
||||
/// hors Windows.
|
||||
pub fn os_err_text(e: &std::io::Error) -> String {
|
||||
let s = e.to_string();
|
||||
let s = match s.rfind(" (os error ") {
|
||||
Some(i) => s[..i].to_string(),
|
||||
None => s,
|
||||
};
|
||||
if cfg!(windows) {
|
||||
return s;
|
||||
}
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
Some(f) => f.to_lowercase().chain(c).collect(),
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// `*PathError` de Go : « op chemin: texte ».
|
||||
fn path_err(op: &str, p: &Path, e: &std::io::Error) -> String {
|
||||
format!("[erreur] {op} {}: {}", p.display(), os_err_text(e))
|
||||
}
|
||||
|
||||
/// `os.ReadFile`, erreurs au format Go (`open …` / `read …`).
|
||||
fn read_file(p: &Path) -> Result<Vec<u8>, String> {
|
||||
let mut f = fs::File::open(p).map_err(|e| path_err("open", p, &e))?;
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).map_err(|e| path_err("read", p, &e))?;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// `isBinary` : un NUL dans les premiers 8 Kio.
|
||||
pub fn is_binary(b: &[u8]) -> bool {
|
||||
b[..b.len().min(8192)].contains(&0)
|
||||
}
|
||||
|
||||
/// Ligne bornée à `READ_MAX_LINE_LEN` caractères (suivie de « … » si coupée).
|
||||
fn cap_line(l: &str) -> String {
|
||||
match l.char_indices().nth(READ_MAX_LINE_LEN) {
|
||||
Some((i, _)) => format!("{}…", &l[..i]),
|
||||
None => l.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entier positif d'un argument JSON (`args["x"].(float64)` puis `int(v)`).
|
||||
pub fn arg_int(args: &Map<String, Value>, k: &str) -> Option<i64> {
|
||||
args.get(k).and_then(Value::as_f64).map(|v| v as i64)
|
||||
}
|
||||
|
||||
pub fn arg_str<'a>(args: &'a Map<String, Value>, k: &str) -> &'a str {
|
||||
args.get(k).and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Ce qu'un outil fichier rend : le résultat, et le chemin LU à noter dans
|
||||
/// le suivi (read réussi).
|
||||
pub struct ReadOut {
|
||||
pub result: String,
|
||||
pub noted: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// `toolRead` : fichier avec numéros de ligne.
|
||||
pub fn tool_read(cwd: &Path, args: &Map<String, Value>, code: bool) -> ReadOut {
|
||||
let err = |result: String| ReadOut {
|
||||
result,
|
||||
noted: None,
|
||||
};
|
||||
let file = arg_str(args, "file");
|
||||
if file.trim().is_empty() {
|
||||
return err("[erreur] chemin vide".into());
|
||||
}
|
||||
let path = resolve_agent_path(cwd, file);
|
||||
if code && !code_path_allowed(cwd, &path) {
|
||||
return err(refused_path_result(file));
|
||||
}
|
||||
let offset = arg_int(args, "offset").filter(|v| *v > 0).unwrap_or(1) as usize;
|
||||
let limit = (arg_int(args, "limit")
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or(READ_DEF_LINES as i64) as usize)
|
||||
.min(READ_MAX_LINES);
|
||||
let b = match read_file(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
if is_binary(&b) {
|
||||
let size = fs::metadata(&path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(b.len() as u64);
|
||||
return err(format!(
|
||||
"[binaire] {file} ({size} octets) — pas un fichier texte"
|
||||
));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let mut total = lines.len();
|
||||
// Fichier fini par \n : la dernière entrée vide n'est pas une ligne.
|
||||
if total > 0 && lines[total - 1].is_empty() {
|
||||
total -= 1;
|
||||
}
|
||||
if offset > total {
|
||||
return err(format!("[erreur] offset {offset} > {total} lignes"));
|
||||
}
|
||||
let end = (offset - 1 + limit).min(total);
|
||||
let mut out = String::new();
|
||||
for (i, l) in lines.iter().enumerate().take(end).skip(offset - 1) {
|
||||
out.push_str(&format!("{}: {}\n", i + 1, cap_line(l)));
|
||||
}
|
||||
if end < total {
|
||||
out.push_str(&format!(
|
||||
"… ({} lignes restantes — relis avec offset={})\n",
|
||||
total - end,
|
||||
end + 1
|
||||
));
|
||||
}
|
||||
ReadOut {
|
||||
result: out.trim_end_matches('\n').to_string(),
|
||||
noted: Some(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parcours à la `filepath.WalkDir` : ordre lexical, liens non suivis,
|
||||
/// dossiers ignorés (`skipDirs`, la racine comprise). `visit` rend `false`
|
||||
/// pour tout arrêter.
|
||||
fn walk(root: &Path, visit: &mut dyn FnMut(&Path, &fs::Metadata) -> bool) {
|
||||
let Ok(meta) = fs::symlink_metadata(root) else {
|
||||
return;
|
||||
};
|
||||
walk_entry(root, &meta, visit);
|
||||
}
|
||||
|
||||
fn walk_entry(
|
||||
path: &Path,
|
||||
meta: &fs::Metadata,
|
||||
visit: &mut dyn FnMut(&Path, &fs::Metadata) -> bool,
|
||||
) -> bool {
|
||||
if !meta.is_dir() {
|
||||
return visit(path, meta);
|
||||
}
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(skip_dir)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Ok(rd) = fs::read_dir(path) else {
|
||||
return true; // dossier illisible : on continue ailleurs
|
||||
};
|
||||
let mut entries: Vec<(String, PathBuf)> = rd
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
||||
.collect();
|
||||
entries.sort();
|
||||
for (_, p) in entries {
|
||||
let Ok(m) = fs::symlink_metadata(&p) else {
|
||||
continue;
|
||||
};
|
||||
if !walk_entry(&p, &m, visit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Chemin relatif à la racine du parcours, en `/` (`.` pour la racine).
|
||||
fn rel_slash(root: &Path, p: &Path) -> String {
|
||||
match p.strip_prefix(root) {
|
||||
Ok(r) if r.as_os_str().is_empty() => ".".into(),
|
||||
Ok(r) => to_slash(r),
|
||||
Err(_) => to_slash(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// `toolGrep` : motif regexp dans les fichiers.
|
||||
pub fn tool_grep(cwd: &Path, args: &Map<String, Value>, code: bool) -> String {
|
||||
let pattern = arg_str(args, "pattern");
|
||||
if pattern.trim().is_empty() {
|
||||
return "[erreur] motif vide".into();
|
||||
}
|
||||
let re = match Regex::new(pattern) {
|
||||
Ok(re) => re,
|
||||
Err(e) => return format!("[erreur] motif invalide : {e}"),
|
||||
};
|
||||
let p = arg_str(args, "path");
|
||||
let root = if !p.trim().is_empty() && p != "." {
|
||||
resolve_agent_path(cwd, p)
|
||||
} else {
|
||||
cwd.to_path_buf()
|
||||
};
|
||||
if code && !code_path_allowed(cwd, &root) {
|
||||
return refused_path_result(&root.display().to_string());
|
||||
}
|
||||
let glob_pat = arg_str(args, "glob");
|
||||
let mut hits: Vec<String> = Vec::new();
|
||||
walk(&root, &mut |path, _| {
|
||||
if !glob_pat.is_empty() {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
if !go_match(glob_pat, &name).unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if hits.len() >= GREP_MAX_HITS {
|
||||
return false;
|
||||
}
|
||||
let Ok(b) = fs::read(path) else {
|
||||
return true;
|
||||
};
|
||||
if is_binary(&b) {
|
||||
return true;
|
||||
}
|
||||
let rel = rel_slash(&root, path);
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
for (i, line) in text.split('\n').enumerate() {
|
||||
if re.is_match(line) {
|
||||
hits.push(format!("{rel}:{}: {}", i + 1, cap_line(line).trim()));
|
||||
if hits.len() >= GREP_MAX_HITS {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
if hits.is_empty() {
|
||||
return "[aucun résultat]".into();
|
||||
}
|
||||
let mut out = hits.join("\n");
|
||||
if hits.len() >= GREP_MAX_HITS {
|
||||
out.push_str(&format!(
|
||||
"\n… (plafond de {GREP_MAX_HITS} résultats atteint — affine le motif)"
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `toolGlob` : fichiers d'un motif (`**` compris), plus récents d'abord.
|
||||
pub fn tool_glob(cwd: &Path, args: &Map<String, Value>) -> String {
|
||||
let pattern = arg_str(args, "pattern");
|
||||
if pattern.trim().is_empty() {
|
||||
return "[erreur] motif vide".into();
|
||||
}
|
||||
let re = match glob_to_regex(pattern) {
|
||||
Ok(re) => re,
|
||||
Err(e) => return format!("[erreur] motif invalide : {e}"),
|
||||
};
|
||||
let mut hits: Vec<(String, std::time::SystemTime)> = Vec::new();
|
||||
walk(cwd, &mut |path, meta| {
|
||||
let rel = rel_slash(cwd, path);
|
||||
if re.is_match(&rel) {
|
||||
hits.push((rel, meta.modified().unwrap_or(std::time::UNIX_EPOCH)));
|
||||
}
|
||||
true
|
||||
});
|
||||
if hits.is_empty() {
|
||||
return "[aucun fichier]".into();
|
||||
}
|
||||
hits.sort_by_key(|h| std::cmp::Reverse(h.1));
|
||||
hits.truncate(GLOB_MAX_HITS);
|
||||
hits.into_iter().map(|h| h.0).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// `globToRegexp` : glob (avec `**`) → expression sur des chemins en `/`.
|
||||
/// `**` = n'importe quelle profondeur, `*` = tout sauf `/`, `?` = un
|
||||
/// caractère sauf `/`.
|
||||
pub fn glob_to_regex(pattern: &str) -> Result<Regex, regex::Error> {
|
||||
let p = if cfg!(windows) {
|
||||
pattern.replace('\\', "/")
|
||||
} else {
|
||||
pattern.to_string()
|
||||
};
|
||||
let p = p.strip_prefix("./").unwrap_or(&p);
|
||||
let mut b = String::from("^");
|
||||
let mut rest = p;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
if rest.starts_with("**/") {
|
||||
b.push_str("(?:[^/]+/)*");
|
||||
rest = &rest[3..];
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("**") {
|
||||
b.push_str(".*");
|
||||
rest = &rest[2..];
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'*' => b.push_str("[^/]*"),
|
||||
'?' => b.push_str("[^/]"),
|
||||
'.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
|
||||
b.push('\\');
|
||||
b.push(c);
|
||||
}
|
||||
_ => b.push(c),
|
||||
}
|
||||
rest = &rest[c.len_utf8()..];
|
||||
}
|
||||
b.push('$');
|
||||
Regex::new(&b)
|
||||
}
|
||||
|
||||
/// `filepath.Match` de Go (`*`, `?`, `[classes]`, `\` d'échappement hors
|
||||
/// Windows), sur un nom sans séparateur. `None` : motif mal formé.
|
||||
pub fn go_match(pattern: &str, name: &str) -> Option<bool> {
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let mut pattern = pattern;
|
||||
let mut name = name;
|
||||
'pat: while !pattern.is_empty() {
|
||||
let (star, chunk, rest) = scan_chunk(pattern);
|
||||
pattern = rest;
|
||||
if star && chunk.is_empty() {
|
||||
return Some(!name.contains(sep));
|
||||
}
|
||||
let (t, ok, err) = match_chunk(chunk, name);
|
||||
if ok && (t.is_empty() || !pattern.is_empty()) {
|
||||
name = t;
|
||||
continue;
|
||||
}
|
||||
if err {
|
||||
return None;
|
||||
}
|
||||
if star {
|
||||
for (i, ch) in name.char_indices() {
|
||||
if ch == sep {
|
||||
break;
|
||||
}
|
||||
let skip = i + ch.len_utf8();
|
||||
// Go avance d'un OCTET ; sur du texte valide, les positions
|
||||
// intermédiaires d'un caractère ne peuvent rien faire correspondre.
|
||||
let (t, ok, err) = match_chunk(chunk, &name[skip..]);
|
||||
if ok {
|
||||
if pattern.is_empty() && !t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
name = t;
|
||||
continue 'pat;
|
||||
}
|
||||
if err {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
while !pattern.is_empty() {
|
||||
let (_, chunk, rest) = scan_chunk(pattern);
|
||||
pattern = rest;
|
||||
if match_chunk(chunk, "").2 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return Some(false);
|
||||
}
|
||||
Some(name.is_empty())
|
||||
}
|
||||
|
||||
fn scan_chunk(mut pattern: &str) -> (bool, &str, &str) {
|
||||
let mut star = false;
|
||||
while let Some(r) = pattern.strip_prefix('*') {
|
||||
pattern = r;
|
||||
star = true;
|
||||
}
|
||||
let b = pattern.as_bytes();
|
||||
let mut inrange = false;
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'\\' if !cfg!(windows) => {
|
||||
if i + 1 < b.len() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'[' => inrange = true,
|
||||
b']' => inrange = false,
|
||||
b'*' if !inrange => break,
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
(star, &pattern[..i], &pattern[i..])
|
||||
}
|
||||
|
||||
/// `matchChunk` : (reste du nom, correspond ?, motif mal formé ?).
|
||||
fn match_chunk<'a>(chunk: &str, s: &'a str) -> (&'a str, bool, bool) {
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let mut chunk = chunk;
|
||||
let mut s = s;
|
||||
let mut failed = false;
|
||||
while let Some(c0) = chunk.chars().next() {
|
||||
if !failed && s.is_empty() {
|
||||
failed = true;
|
||||
}
|
||||
match c0 {
|
||||
'[' => {
|
||||
let mut r = '\0';
|
||||
if !failed {
|
||||
let ch = s.chars().next().unwrap_or('\0');
|
||||
r = ch;
|
||||
s = &s[ch.len_utf8()..];
|
||||
}
|
||||
chunk = &chunk[1..];
|
||||
let mut negated = false;
|
||||
if let Some(rest) = chunk.strip_prefix('^') {
|
||||
negated = true;
|
||||
chunk = rest;
|
||||
}
|
||||
let mut matched = false;
|
||||
let mut nrange = 0;
|
||||
loop {
|
||||
if nrange > 0
|
||||
&& let Some(rest) = chunk.strip_prefix(']')
|
||||
{
|
||||
chunk = rest;
|
||||
break;
|
||||
}
|
||||
let Some((lo, rest)) = get_esc(chunk) else {
|
||||
return ("", false, true);
|
||||
};
|
||||
chunk = rest;
|
||||
let mut hi = lo;
|
||||
if let Some(rest) = chunk.strip_prefix('-') {
|
||||
let Some((h, rest)) = get_esc(rest) else {
|
||||
return ("", false, true);
|
||||
};
|
||||
hi = h;
|
||||
chunk = rest;
|
||||
}
|
||||
if lo <= r && r <= hi {
|
||||
matched = true;
|
||||
}
|
||||
nrange += 1;
|
||||
}
|
||||
if matched == negated {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
'?' => {
|
||||
if !failed {
|
||||
let ch = s.chars().next().unwrap_or('\0');
|
||||
if ch == sep {
|
||||
failed = true;
|
||||
}
|
||||
s = &s[ch.len_utf8()..];
|
||||
}
|
||||
chunk = &chunk[1..];
|
||||
}
|
||||
_ => {
|
||||
let mut lit = c0;
|
||||
if c0 == '\\' && !cfg!(windows) {
|
||||
chunk = &chunk[1..];
|
||||
match chunk.chars().next() {
|
||||
Some(c) => lit = c,
|
||||
None => return ("", false, true),
|
||||
}
|
||||
}
|
||||
if !failed {
|
||||
// Go compare OCTET par octet : même chose, caractère
|
||||
// entier contre caractère entier, sur du texte valide.
|
||||
match s.chars().next() {
|
||||
Some(c) if c == lit => s = &s[c.len_utf8()..],
|
||||
Some(c) => {
|
||||
failed = true;
|
||||
s = &s[c.len_utf8()..];
|
||||
}
|
||||
None => failed = true,
|
||||
}
|
||||
}
|
||||
chunk = &chunk[lit.len_utf8()..];
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
("", false, false)
|
||||
} else {
|
||||
(s, true, false)
|
||||
}
|
||||
}
|
||||
|
||||
/// `getEsc` : un caractère de classe (échappé ou non) ; `None` : motif mal
|
||||
/// formé (vide, `-` ou `]` en tête, ou rien après).
|
||||
fn get_esc(chunk: &str) -> Option<(char, &str)> {
|
||||
let mut chunk = chunk;
|
||||
match chunk.chars().next() {
|
||||
None | Some('-') | Some(']') => return None,
|
||||
Some('\\') if !cfg!(windows) => {
|
||||
chunk = &chunk[1..];
|
||||
chunk.chars().next()?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let r = chunk.chars().next()?;
|
||||
let rest = &chunk[r.len_utf8()..];
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((r, rest))
|
||||
}
|
||||
|
||||
/// Verrou par fichier : sérialise deux écritures du même fichier.
|
||||
fn file_mu(path: &Path) -> Arc<Mutex<()>> {
|
||||
static MAP: LazyLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
MAP.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.entry(super::policy::clean(path))
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// `os.WriteFile` : écrit, crée au besoin (0644) ; un fichier existant garde
|
||||
/// son mode (un script 0755 réécrit reste exécutable).
|
||||
fn write_file(path: &Path, content: &[u8]) -> Result<(), String> {
|
||||
let mut opts = fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o644);
|
||||
}
|
||||
let mut f = opts.open(path).map_err(|e| path_err("open", path, &e))?;
|
||||
std::io::Write::write_all(&mut f, content).map_err(|e| path_err("write", path, &e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `fileWrite` : écrit le contenu tel quel, dossiers parents compris.
|
||||
pub fn file_write(cwd: &Path, memory_dir: &Path, path: &str, content: &str) -> String {
|
||||
if path.trim().is_empty() {
|
||||
return "[erreur] chemin vide".into();
|
||||
}
|
||||
let path = resolve_agent_path(cwd, path);
|
||||
if let Some(msg) = guard_tool_only_path(&path, memory_dir) {
|
||||
return msg;
|
||||
}
|
||||
let mu = file_mu(&path);
|
||||
let _g = mu.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(dir) = path.parent()
|
||||
&& !dir.as_os_str().is_empty()
|
||||
&& dir != Path::new(".")
|
||||
&& let Err(e) = fs::create_dir_all(dir)
|
||||
{
|
||||
return path_err("mkdir", dir, &e);
|
||||
}
|
||||
let existed = fs::metadata(&path).is_ok();
|
||||
if let Err(e) = write_file(&path, content.as_bytes()) {
|
||||
return e;
|
||||
}
|
||||
let verb = if existed { "réécrit" } else { "créé" };
|
||||
format!("[ok] {} {verb} ({} octets)", path.display(), content.len())
|
||||
}
|
||||
|
||||
/// `fileEdit` : UN remplacement exact (`old` doit apparaître une seule fois).
|
||||
/// Un fichier en CRLF est comparé en LF et réécrit en CRLF.
|
||||
pub fn file_edit(cwd: &Path, memory_dir: &Path, path: &str, old: &str, new: &str) -> String {
|
||||
if path.trim().is_empty() {
|
||||
return "[erreur] chemin vide".into();
|
||||
}
|
||||
if old.is_empty() {
|
||||
return "[erreur] old vide".into();
|
||||
}
|
||||
let path = resolve_agent_path(cwd, path);
|
||||
if let Some(msg) = guard_tool_only_path(&path, memory_dir) {
|
||||
return msg;
|
||||
}
|
||||
let mu = file_mu(&path);
|
||||
let _g = mu.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Octets, pas texte : un fichier hors UTF-8 (latin-1…) garde intact ce que
|
||||
// le remplacement ne touche pas.
|
||||
let content = match read_file(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let (old, new) = (old.as_bytes(), new.as_bytes());
|
||||
let crlf = contains(&content, b"\r\n");
|
||||
let search = if crlf && !contains(old, b"\r\n") {
|
||||
replace_all(&content, b"\r\n", b"\n")
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
let n = count(&search, old);
|
||||
if n == 0 {
|
||||
if !new.is_empty() && contains(&search, new) {
|
||||
return "[ok] déjà à jour — le fichier contient déjà cette modification".into();
|
||||
}
|
||||
return "[erreur] old introuvable dans le fichier".into();
|
||||
}
|
||||
if n > 1 {
|
||||
return format!(
|
||||
"[erreur] old apparaît {n} fois — ajoute du contexte pour le rendre unique"
|
||||
);
|
||||
}
|
||||
let i = find(&search, old).unwrap_or(0);
|
||||
let mut updated = [&search[..i], new, &search[i + old.len()..]].concat();
|
||||
if search != content {
|
||||
updated = replace_all(&updated, b"\n", b"\r\n");
|
||||
}
|
||||
if let Err(e) = write_file(&path, &updated) {
|
||||
return e;
|
||||
}
|
||||
format!("[ok] {} modifié (1 remplacement)", path.display())
|
||||
}
|
||||
|
||||
fn find(h: &[u8], n: &[u8]) -> Option<usize> {
|
||||
if n.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
h.windows(n.len()).position(|w| w == n)
|
||||
}
|
||||
|
||||
fn contains(h: &[u8], n: &[u8]) -> bool {
|
||||
find(h, n).is_some()
|
||||
}
|
||||
|
||||
/// Occurrences sans chevauchement (`strings.Count`, `old` non vide).
|
||||
fn count(h: &[u8], n: &[u8]) -> usize {
|
||||
let (mut c, mut i) = (0, 0);
|
||||
while let Some(j) = find(&h[i..], n) {
|
||||
c += 1;
|
||||
i += j + n.len();
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn replace_all(h: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
let mut i = 0;
|
||||
while let Some(j) = find(&h[i..], from) {
|
||||
out.extend_from_slice(&h[i..i + j]);
|
||||
out.extend_from_slice(to);
|
||||
i += j + from.len();
|
||||
}
|
||||
out.extend_from_slice(&h[i..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// `toolAsk` : la question part vers l'UI par un événement dédié ; ici, dire
|
||||
/// au modèle de clore son tour.
|
||||
pub fn tool_ask(args: &Map<String, Value>) -> String {
|
||||
if arg_str(args, "question").trim().is_empty() {
|
||||
return "[erreur] question vide".into();
|
||||
}
|
||||
"[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message.".into()
|
||||
}
|
||||
|
||||
/// `askOptions` : options non vides, 4 au plus.
|
||||
pub fn ask_options(args: &Map<String, Value>) -> Vec<String> {
|
||||
args.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.take(4)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn args(v: Value) -> Map<String, Value> {
|
||||
v.as_object().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filepath_match_de_go() {
|
||||
for (p, n, want) in [
|
||||
("*.go", "a.go", Some(true)),
|
||||
("*.go", "a.rs", Some(false)),
|
||||
("a?c", "abc", Some(true)),
|
||||
("[a-c]x", "bx", Some(true)),
|
||||
("[^a-c]x", "bx", Some(false)),
|
||||
("*", "", Some(true)),
|
||||
("*.é", "été.é", Some(true)),
|
||||
("[", "a", None),
|
||||
("a[", "b", None),
|
||||
("\\*", "*", Some(true)),
|
||||
] {
|
||||
assert_eq!(go_match(p, n), want, "{p} {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_en_regex() {
|
||||
let re = glob_to_regex("src/**/*.rs").unwrap();
|
||||
assert!(re.is_match("src/a.rs") && re.is_match("src/x/y/a.rs"));
|
||||
assert!(!re.is_match("src/a.rs.bak") && !re.is_match("a.rs"));
|
||||
assert!(glob_to_regex("./**").unwrap().is_match("a/b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lecture_numerotee() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
fs::write(d.path().join("a.txt"), "un\ndeux\ntrois\n").unwrap();
|
||||
let r = tool_read(
|
||||
d.path(),
|
||||
&args(json!({"file":"a.txt","offset":2,"limit":1})),
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
r.result,
|
||||
"2: deux\n… (1 lignes restantes — relis avec offset=3)"
|
||||
);
|
||||
assert!(r.noted.is_some());
|
||||
let r = tool_read(d.path(), &args(json!({"file":"absent"})), false);
|
||||
assert!(
|
||||
r.result.starts_with("[erreur] open ")
|
||||
&& r.result.ends_with(": no such file or directory")
|
||||
|| cfg!(windows)
|
||||
);
|
||||
assert!(r.noted.is_none());
|
||||
assert_eq!(
|
||||
tool_read(d.path(), &args(json!({"file":" "})), false).result,
|
||||
"[erreur] chemin vide"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edition_crlf_et_deja_fait() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let mem = d.path().join("memory");
|
||||
fs::write(d.path().join("w.txt"), "a\r\nb\r\n").unwrap();
|
||||
assert_eq!(
|
||||
file_edit(d.path(), &mem, "w.txt", "a\nb", "a\nc"),
|
||||
format!(
|
||||
"[ok] {} modifié (1 remplacement)",
|
||||
d.path().join("w.txt").display()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(d.path().join("w.txt")).unwrap(),
|
||||
"a\r\nc\r\n"
|
||||
);
|
||||
assert!(file_edit(d.path(), &mem, "w.txt", "a\nb", "a\nc").starts_with("[ok] déjà à jour"));
|
||||
assert!(file_write(d.path(), &mem, "memory/x.md", "x").starts_with("[refusé]"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Outils git du mode Code — port de `code_git.go` : `git_status`,
|
||||
//! `git_diff`, `git_clone`. Sortie bornée, jamais d'invite interactive, et un
|
||||
//! clone qui atterrit TOUJOURS dans le dossier de la discussion.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::files::arg_str;
|
||||
use super::policy::clean;
|
||||
|
||||
/// Sortie gardée (sa fin).
|
||||
const GIT_MAX_OUTPUT: usize = 8000;
|
||||
const STATUS_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const DIFF_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const CLONE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// `str` du Go : chaîne telle quelle, nombre sans décimales, rien → "".
|
||||
fn go_str(v: Option<&Value>) -> String {
|
||||
match v {
|
||||
None | Some(Value::Null) => String::new(),
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => match n.as_f64() {
|
||||
Some(f) => format!("{f:.0}"),
|
||||
None => n.to_string(),
|
||||
},
|
||||
Some(other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `exec.LookPath` : le programme est-il dans le PATH ?
|
||||
fn look_path(name: &str) -> bool {
|
||||
let Some(path) = std::env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
let exts: Vec<String> = if cfg!(windows) {
|
||||
std::env::var("PATHEXT")
|
||||
.unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into())
|
||||
.split(';')
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
} else {
|
||||
vec![String::new()]
|
||||
};
|
||||
std::env::split_paths(&path).any(|dir| {
|
||||
exts.iter().any(|e| {
|
||||
let p = dir.join(format!("{name}{e}"));
|
||||
p.is_file() && is_executable(&p)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(p).is_ok_and(|m| m.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_executable(_p: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `(*exec.ExitError).Error()` : « exit status N », « signal: killed ».
|
||||
fn exit_text(st: &std::process::ExitStatus) -> String {
|
||||
if let Some(c) = st.code() {
|
||||
return format!("exit status {c}");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
if let Some(sig) = st.signal() {
|
||||
let name = match sig {
|
||||
2 => "interrupt".to_string(),
|
||||
9 => "killed".to_string(),
|
||||
15 => "terminated".to_string(),
|
||||
n => format!("signal {n}"),
|
||||
};
|
||||
return format!("signal: {name}");
|
||||
}
|
||||
}
|
||||
st.to_string()
|
||||
}
|
||||
|
||||
/// La fin d'un texte, `n` caractères au plus (`tailRunes`).
|
||||
fn tail_chars(s: &str, n: usize) -> &str {
|
||||
let count = s.chars().count();
|
||||
if count <= n {
|
||||
return s;
|
||||
}
|
||||
let start = s.char_indices().nth(count - n).map_or(0, |(i, _)| i);
|
||||
&s[start..]
|
||||
}
|
||||
|
||||
/// `runGitIn` : git dans `dir`, sorties mêlées (comme `CombinedOutput`),
|
||||
/// délai borné, jamais d'invite (mot de passe, hôte inconnu).
|
||||
pub async fn run_git_in<S: AsRef<str>>(dir: &Path, timeout: Duration, args: &[S]) -> String {
|
||||
let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
|
||||
if !look_path("git") {
|
||||
return "[erreur] git n'est pas installé sur cette machine".into();
|
||||
}
|
||||
// Dossier absent : le message de Go (exec, Dir).
|
||||
match std::fs::metadata(dir) {
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"[erreur] chdir {}: {}",
|
||||
dir.display(),
|
||||
super::files::os_err_text(&e)
|
||||
);
|
||||
}
|
||||
Ok(m) if !m.is_dir() => {
|
||||
return format!("[erreur] chdir {}: not a directory", dir.display());
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
let (reader, writer) = match std::io::pipe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return format!("[erreur] {e}"),
|
||||
};
|
||||
let writer2 = match writer.try_clone() {
|
||||
Ok(w) => w,
|
||||
Err(e) => return format!("[erreur] {e}"),
|
||||
};
|
||||
let mut cmd = tokio::process::Command::new("git");
|
||||
cmd.args(&args)
|
||||
.current_dir(dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(writer)
|
||||
.stderr(writer2)
|
||||
.kill_on_drop(true)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes")
|
||||
.env_remove(crate::auth::INTERNAL_ENV);
|
||||
let child = cmd.spawn();
|
||||
// Les extrémités d'écriture gardées par la commande : fermées, sinon la
|
||||
// lecture n'aurait jamais de fin.
|
||||
drop(cmd);
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(e) => return format!("[erreur] {e}"),
|
||||
};
|
||||
let buf = Arc::new(Mutex::new(Vec::new()));
|
||||
let b = buf.clone();
|
||||
let read = tokio::task::spawn_blocking(move || {
|
||||
let mut r = reader;
|
||||
let mut chunk = [0u8; 8192];
|
||||
while let Ok(n) = r.read(&mut chunk) {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
b.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
});
|
||||
let status = match tokio::time::timeout(timeout, child.wait()).await {
|
||||
Ok(st) => st,
|
||||
Err(_) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
return format!("[timeout] git {}", args.join(" "));
|
||||
}
|
||||
};
|
||||
// Sortie fermée par git ; un petit-enfant qui la tiendrait encore ne
|
||||
// bloque pas le tour.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), read).await;
|
||||
let out = String::from_utf8_lossy(&buf.lock().unwrap_or_else(|e| e.into_inner())).into_owned();
|
||||
let mut s = tail_chars(out.trim(), GIT_MAX_OUTPUT).to_string();
|
||||
match status {
|
||||
Ok(st) if st.success() => {}
|
||||
Ok(st) => {
|
||||
if s.is_empty() {
|
||||
s = exit_text(&st);
|
||||
}
|
||||
return format!("[erreur] {s}");
|
||||
}
|
||||
Err(e) => return format!("[erreur] {e}"),
|
||||
}
|
||||
if s.is_empty() {
|
||||
s = "(aucune sortie)".into();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `gitRepoDir` : le sous-dossier demandé, la racine si c'est un dépôt,
|
||||
/// sinon l'unique sous-dossier qui en est un (plusieurs : à préciser).
|
||||
pub fn git_repo_dir(base: &Path, dir: &str) -> Result<PathBuf, String> {
|
||||
let dir = dir.trim();
|
||||
if !dir.is_empty() {
|
||||
if dir.contains("..") || Path::new(dir).is_absolute() {
|
||||
return Err(format!("dossier invalide : {dir}"));
|
||||
}
|
||||
return Ok(clean(&base.join(dir)));
|
||||
}
|
||||
if base.join(".git").exists() {
|
||||
return Ok(base.to_path_buf());
|
||||
}
|
||||
let mut repos: Vec<String> = std::fs::read_dir(base)
|
||||
.map(|rd| {
|
||||
rd.filter_map(Result::ok)
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
||||
.filter(|e| e.path().join(".git").exists())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
repos.sort();
|
||||
match repos.len() {
|
||||
0 => Ok(base.to_path_buf()), // git dira « not a git repository »
|
||||
1 => Ok(base.join(&repos[0])),
|
||||
_ => Err(format!(
|
||||
"plusieurs dépôts ici ({}) : précise dir",
|
||||
repos.join(", ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `git_status`.
|
||||
pub async fn tool_git_status(cwd: &Path, args: &Map<String, Value>) -> String {
|
||||
match git_repo_dir(cwd, &go_str(args.get("dir"))) {
|
||||
Ok(dir) => {
|
||||
run_git_in(
|
||||
&dir,
|
||||
STATUS_TIMEOUT,
|
||||
&["status", "--porcelain=v1", "--branch"],
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(e) => format!("[erreur] {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `git_diff` : index et arbre de travail ensemble (`diff HEAD`) ; dépôt
|
||||
/// sans commit : le diff simple.
|
||||
pub async fn tool_git_diff(cwd: &Path, args: &Map<String, Value>) -> String {
|
||||
let dir = match git_repo_dir(cwd, &go_str(args.get("dir"))) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return format!("[erreur] {e}"),
|
||||
};
|
||||
let file = arg_str(args, "file");
|
||||
let with_file = |head: &[&str]| -> Vec<String> {
|
||||
let mut a: Vec<String> = head.iter().map(|s| s.to_string()).collect();
|
||||
if !file.trim().is_empty() {
|
||||
a.extend(["--".to_string(), file.to_string()]);
|
||||
}
|
||||
a
|
||||
};
|
||||
let out = run_git_in(&dir, DIFF_TIMEOUT, &with_file(&["diff", "HEAD"])).await;
|
||||
if out.starts_with("[erreur]") && out.contains("HEAD") {
|
||||
return run_git_in(&dir, DIFF_TIMEOUT, &with_file(&["diff"])).await;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// https:// ou git@hôte: — ni chemin local ni protocole exotique (file://,
|
||||
/// ext:: qui exécute une commande).
|
||||
static GIT_URL: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^(https://[A-Za-z0-9_.\-]+(:[0-9]+)?/|git@[A-Za-z0-9_.\-]+:)[A-Za-z0-9_.\-~/]+$")
|
||||
.expect("motif d'URL git")
|
||||
});
|
||||
|
||||
/// `filepath.Base`.
|
||||
fn go_base(p: &str) -> String {
|
||||
let seps: &[char] = if cfg!(windows) { &['/', '\\'] } else { &['/'] };
|
||||
let t = p.trim_end_matches(seps);
|
||||
if t.is_empty() {
|
||||
return if p.is_empty() { ".".into() } else { "/".into() };
|
||||
}
|
||||
t.rsplit(seps).next().unwrap_or(t).to_string()
|
||||
}
|
||||
|
||||
/// `git_clone` : dans le dossier de la discussion, quoi qu'il arrive.
|
||||
pub async fn tool_git_clone(cwd: &Path, args: &Map<String, Value>) -> String {
|
||||
let url = arg_str(args, "url").trim();
|
||||
let url = format!("{}.git", url.strip_suffix(".git").unwrap_or(url).trim());
|
||||
let bare = url.strip_suffix(".git").unwrap_or(&url);
|
||||
if !GIT_URL.is_match(bare) && !GIT_URL.is_match(&url) {
|
||||
return "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés".into();
|
||||
}
|
||||
let mut dir = arg_str(args, "dir").trim().to_string();
|
||||
if dir.is_empty() {
|
||||
dir = go_base(bare);
|
||||
}
|
||||
if dir.contains("..") || Path::new(&dir).is_absolute() {
|
||||
return "[erreur] dossier cible invalide".into();
|
||||
}
|
||||
let target = clean(&cwd.join(&dir));
|
||||
if std::fs::metadata(&target).is_ok() {
|
||||
return format!("[erreur] le dossier {dir} existe déjà");
|
||||
}
|
||||
let target_s = target.to_string_lossy().into_owned();
|
||||
let out = run_git_in(cwd, CLONE_TIMEOUT, &["clone", "--", &url, &target_s]).await;
|
||||
if out.starts_with("[erreur]") || out.starts_with("[timeout]") {
|
||||
return out;
|
||||
}
|
||||
format!("[ok] cloné dans {dir}/\n{out}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_et_str_comme_le_go() {
|
||||
assert_eq!(go_base("https://h/u/depot"), "depot");
|
||||
assert_eq!(go_base("git@h:u/depot"), "depot");
|
||||
assert_eq!(go_base("a/"), "a");
|
||||
assert_eq!(go_base(""), ".");
|
||||
assert_eq!(go_str(Some(&serde_json::json!(3.0))), "3");
|
||||
assert_eq!(go_str(None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_acceptees() {
|
||||
for u in [
|
||||
"https://github.com/a/b",
|
||||
"https://h:8443/a/b.git",
|
||||
"git@github.com:a/b.git",
|
||||
] {
|
||||
assert!(GIT_URL.is_match(u), "{u}");
|
||||
}
|
||||
for u in [
|
||||
"file:///etc",
|
||||
"ext::sh -c id",
|
||||
"/srv/depot",
|
||||
"https://h/a b",
|
||||
"https://hé/a",
|
||||
] {
|
||||
assert!(!GIT_URL.is_match(u), "{u}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depot_trouve() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let b = d.path();
|
||||
assert_eq!(git_repo_dir(b, "").unwrap(), b);
|
||||
std::fs::create_dir_all(b.join("un/.git")).unwrap();
|
||||
assert_eq!(git_repo_dir(b, "").unwrap(), b.join("un"));
|
||||
std::fs::create_dir_all(b.join("deux/.git")).unwrap();
|
||||
assert_eq!(
|
||||
git_repo_dir(b, "").unwrap_err(),
|
||||
"plusieurs dépôts ici (deux, un) : précise dir"
|
||||
);
|
||||
assert_eq!(git_repo_dir(b, "deux").unwrap(), b.join("deux"));
|
||||
assert!(git_repo_dir(b, "../x").is_err() && git_repo_dir(b, "/x").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Outils de l'agent exécutés par la façade (lot 7 de la migration).
|
||||
//!
|
||||
//! Quand la façade tient la conversation (`LOKI_CHAT=rust`), les outils
|
||||
//! fichiers, le shell et git s'exécutent ici : `read`, `grep`, `glob`,
|
||||
//! `write`, `edit`, `bash`, `ask`, `git_status`, `git_diff`, `git_clone`. Les
|
||||
//! autres (mémoire, web, tâches, navigateur, MCP,
|
||||
//! sous-agents, critères…) restent au Go (`/internal/v1/tool/exec`), de même
|
||||
//! que `write`/`edit`/`bash` quand l'agent vise un poste distant.
|
||||
//!
|
||||
//! Ce que ces outils partagent avec le reste de Loki reste au Go, en un seul
|
||||
//! exemplaire ([`ToolState`]) : le dossier de travail de la discussion, le
|
||||
//! dossier mémoire, la cible distante, le suivi « lu avant d'écrire » (aussi
|
||||
//! tenu par les sous-agents et les tâches, qui tournent là-bas) et les
|
||||
//! diagnostics LSP. Les garde-fous sont ceux du Go, dans le même ordre.
|
||||
|
||||
pub mod diff;
|
||||
pub mod files;
|
||||
pub mod git;
|
||||
pub mod policy;
|
||||
pub mod shell;
|
||||
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::chat::turn::ToolOutcome;
|
||||
use crate::chat::types::AskEvent;
|
||||
use crate::store::Store;
|
||||
|
||||
/// Ce que les outils doivent savoir avant de s'exécuter.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ToolCtx {
|
||||
/// Dossier de travail (`agentCwd`).
|
||||
pub cwd: PathBuf,
|
||||
/// Dossier interdit au shell et à write/edit (`memoryDir`).
|
||||
pub memory_dir: PathBuf,
|
||||
/// Poste distant visé ("" : cette machine).
|
||||
pub target: String,
|
||||
/// Mode Code : chemins bornés au dossier, lu avant d'écrire.
|
||||
pub code: bool,
|
||||
}
|
||||
|
||||
/// L'état partagé avec le Go.
|
||||
pub trait ToolState: Send + Sync {
|
||||
/// Le contexte du moment ; `Err` : outils locaux indisponibles.
|
||||
fn context(&self) -> impl Future<Output = Result<ToolCtx, String>> + Send;
|
||||
/// `trackerNoteRead`.
|
||||
fn note_read(&self, path: &Path) -> impl Future<Output = ()> + Send;
|
||||
/// `trackerCheck` : le refus, ou `None`.
|
||||
fn check(&self, path: &Path, for_write: bool) -> impl Future<Output = Option<String>> + Send;
|
||||
/// Après une écriture réussie : `trackerNoteWrite` puis le bloc LSP.
|
||||
fn wrote(&self, path: &Path) -> impl Future<Output = String> + Send;
|
||||
}
|
||||
|
||||
/// Outils exécutés ici (hors cible distante pour write/edit/bash).
|
||||
pub fn is_local(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"read"
|
||||
| "grep"
|
||||
| "glob"
|
||||
| "write"
|
||||
| "edit"
|
||||
| "bash"
|
||||
| "ask"
|
||||
| "git_status"
|
||||
| "git_diff"
|
||||
| "git_clone"
|
||||
)
|
||||
}
|
||||
|
||||
fn outcome(result: String) -> ToolOutcome {
|
||||
ToolOutcome {
|
||||
result,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Result<T, String> {
|
||||
tokio::task::spawn_blocking(f)
|
||||
.await
|
||||
.map_err(|e| format!("[erreur] outil interrompu : {e}"))
|
||||
}
|
||||
|
||||
/// `codeWriteGuard` : en mode Code, borne de chemin puis « lu avant
|
||||
/// d'écrire ». `None` : l'écriture peut avoir lieu.
|
||||
async fn code_write_guard<S: ToolState>(
|
||||
st: &S,
|
||||
ctx: &ToolCtx,
|
||||
file: &str,
|
||||
for_write: bool,
|
||||
) -> Option<String> {
|
||||
if !ctx.code {
|
||||
return None;
|
||||
}
|
||||
let path = policy::resolve_agent_path(&ctx.cwd, file);
|
||||
if !policy::code_path_allowed(&ctx.cwd, &path) {
|
||||
return Some(policy::refused_path_result(file));
|
||||
}
|
||||
st.check(&path, for_write).await
|
||||
}
|
||||
|
||||
/// Exécute un outil ici ; `None` : c'est au Go de le faire (outil non porté,
|
||||
/// cible distante, contexte indisponible).
|
||||
pub async fn exec_local<S: ToolState>(
|
||||
st: &S,
|
||||
code: bool,
|
||||
name: &str,
|
||||
args: &Map<String, Value>,
|
||||
label: &str,
|
||||
) -> Option<ToolOutcome> {
|
||||
if !is_local(name) {
|
||||
return None;
|
||||
}
|
||||
let mut ctx = match st.context().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("[outils] contexte indisponible, exécution par le Go : {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
ctx.code = code;
|
||||
let remote = !ctx.target.is_empty();
|
||||
let out = match name {
|
||||
"read" => {
|
||||
let (cwd, a) = (ctx.cwd.clone(), args.clone());
|
||||
let r = match blocking(move || files::tool_read(&cwd, &a, code)).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(outcome(e)),
|
||||
};
|
||||
if let Some(p) = &r.noted {
|
||||
st.note_read(p).await;
|
||||
}
|
||||
outcome(r.result)
|
||||
}
|
||||
"grep" => {
|
||||
let (cwd, a) = (ctx.cwd.clone(), args.clone());
|
||||
outcome(
|
||||
blocking(move || files::tool_grep(&cwd, &a, code))
|
||||
.await
|
||||
.unwrap_or_else(|e| e),
|
||||
)
|
||||
}
|
||||
"glob" => {
|
||||
let (cwd, a) = (ctx.cwd.clone(), args.clone());
|
||||
outcome(
|
||||
blocking(move || files::tool_glob(&cwd, &a))
|
||||
.await
|
||||
.unwrap_or_else(|e| e),
|
||||
)
|
||||
}
|
||||
// git tourne toujours sur cette machine, cible distante ou non (comme
|
||||
// le Go).
|
||||
"git_status" => outcome(git::tool_git_status(&ctx.cwd, args).await),
|
||||
"git_diff" => outcome(git::tool_git_diff(&ctx.cwd, args).await),
|
||||
"git_clone" => outcome(git::tool_git_clone(&ctx.cwd, args).await),
|
||||
"ask" => {
|
||||
let result = files::tool_ask(args);
|
||||
let ask = (!result.starts_with("[erreur]")).then(|| AskEvent {
|
||||
question: files::arg_str(args, "question").to_string(),
|
||||
options: files::ask_options(args),
|
||||
});
|
||||
ToolOutcome {
|
||||
result,
|
||||
ask,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
"write" if !remote => {
|
||||
let content = files::arg_str(args, "content").to_string();
|
||||
if let Some(msg) = code_write_guard(st, &ctx, label, true).await {
|
||||
return Some(outcome(msg));
|
||||
}
|
||||
let (cwd, mem, l, c) = (
|
||||
ctx.cwd.clone(),
|
||||
ctx.memory_dir.clone(),
|
||||
label.to_string(),
|
||||
content.clone(),
|
||||
);
|
||||
let mut result = match blocking(move || files::file_write(&cwd, &mem, &l, &c)).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(outcome(e)),
|
||||
};
|
||||
let mut o = ToolOutcome::default();
|
||||
if !result.starts_with("[erreur]") {
|
||||
(o.diff, o.added) = diff::added_diff(&content);
|
||||
result += &st.wrote(&policy::resolve_agent_path(&ctx.cwd, label)).await;
|
||||
}
|
||||
o.result = result;
|
||||
o
|
||||
}
|
||||
"edit" if !remote => {
|
||||
let (old, new) = (
|
||||
files::arg_str(args, "old").to_string(),
|
||||
files::arg_str(args, "new").to_string(),
|
||||
);
|
||||
if let Some(msg) = code_write_guard(st, &ctx, label, false).await {
|
||||
return Some(outcome(msg));
|
||||
}
|
||||
let (cwd, mem, l, o2, n2) = (
|
||||
ctx.cwd.clone(),
|
||||
ctx.memory_dir.clone(),
|
||||
label.to_string(),
|
||||
old.clone(),
|
||||
new.clone(),
|
||||
);
|
||||
let mut result =
|
||||
match blocking(move || files::file_edit(&cwd, &mem, &l, &o2, &n2)).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(outcome(e)),
|
||||
};
|
||||
let mut o = ToolOutcome::default();
|
||||
if !result.starts_with("[erreur]") {
|
||||
(o.diff, o.added, o.removed) = diff::line_diff(&old, &new);
|
||||
result += &st.wrote(&policy::resolve_agent_path(&ctx.cwd, label)).await;
|
||||
}
|
||||
o.result = result;
|
||||
o
|
||||
}
|
||||
"bash" if !remote || policy::dangerous_command(label).is_some() => {
|
||||
let timeout = files::arg_int(args, "timeout").unwrap_or(0);
|
||||
// Refusées AVANT toute exécution, cible distante comprise.
|
||||
if let Some(reason) = policy::dangerous_command(label) {
|
||||
return Some(outcome(policy::refused_command_result(reason)));
|
||||
}
|
||||
// Mode Code : « cmd & » laisse un orphelin sans sortie ni arrêt.
|
||||
if code && shell::trailing_background(label) {
|
||||
return Some(outcome("[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash.".into()));
|
||||
}
|
||||
outcome(shell::run_shell(&ctx.cwd, &ctx.memory_dir, label, timeout).await)
|
||||
}
|
||||
_ => return None, // cible distante : le Go passe par le poste
|
||||
};
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// [`ToolState`] servi par l'API interne du Go.
|
||||
#[derive(Clone)]
|
||||
pub struct GoToolState {
|
||||
pub store: Store,
|
||||
/// Capacités du tour (pour les diagnostics LSP).
|
||||
pub caps: Value,
|
||||
}
|
||||
|
||||
impl ToolState for GoToolState {
|
||||
async fn context(&self) -> Result<ToolCtx, String> {
|
||||
let (status, v) = self
|
||||
.store
|
||||
.internal_post("/internal/v1/tool/context", &json!({}))
|
||||
.await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("contexte des outils : {status}"));
|
||||
}
|
||||
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
Ok(ToolCtx {
|
||||
cwd: PathBuf::from(s("cwd")),
|
||||
memory_dir: PathBuf::from(s("memory_dir")),
|
||||
target: s("target"),
|
||||
code: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn note_read(&self, path: &Path) {
|
||||
let body = json!({ "op": "read", "path": path });
|
||||
if let Err(e) = self
|
||||
.store
|
||||
.internal_post("/internal/v1/tool/tracker", &body)
|
||||
.await
|
||||
{
|
||||
eprintln!("[outils] lecture non notée : {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn check(&self, path: &Path, for_write: bool) -> Option<String> {
|
||||
let body = json!({ "op": "check", "path": path, "for_write": for_write });
|
||||
match self
|
||||
.store
|
||||
.internal_post("/internal/v1/tool/tracker", &body)
|
||||
.await
|
||||
{
|
||||
Ok((st, v)) if st.is_success() => v
|
||||
.get("refusal")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|r| !r.is_empty())
|
||||
.map(str::to_string),
|
||||
// Sans le suivi, écrire serait écrire à l'aveugle : refus.
|
||||
Ok((st, _)) => Some(format!("[erreur] suivi des lectures indisponible : {st}")),
|
||||
Err(e) => Some(format!("[erreur] suivi des lectures indisponible : {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wrote(&self, path: &Path) -> String {
|
||||
let body = json!({ "path": path, "caps": self.caps });
|
||||
match self
|
||||
.store
|
||||
.internal_post("/internal/v1/tool/wrote", &body)
|
||||
.await
|
||||
{
|
||||
Ok((st, v)) if st.is_success() => v
|
||||
.get("lsp")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
Ok(_) | Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Chemins et garde-fous des outils de l'agent — port de `code_policy.go`,
|
||||
//! `chat_workspace.go` (`resolveAgentPath`) et `chat_scripts.go` (dossier
|
||||
//! mémoire réservé à ses outils).
|
||||
//!
|
||||
//! - [`dangerous_command`] : commandes CATASTROPHIQUES refusées (effacement
|
||||
//! de disque, arrêt machine, fork bomb), mode chat compris ;
|
||||
//! - [`code_path_allowed`] : en mode Code, les chemins des outils fichiers
|
||||
//! sont bornés au dossier de la discussion, liens symboliques résolus ;
|
||||
//! - [`guard_tool_only_path`] / [`guard_tool_only_command`] : le dossier
|
||||
//! mémoire n'est jamais touché par write/edit/bash, seulement par mem_*.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// `filepath.Clean` : forme lexicale la plus courte (`.` et `..` résolus sans
|
||||
/// toucher au disque, séparateurs doublés retirés). Vide → `.`.
|
||||
pub fn clean(p: &Path) -> PathBuf {
|
||||
let mut out: Vec<Component> = Vec::new();
|
||||
let mut rooted = false;
|
||||
for c in p.components() {
|
||||
match c {
|
||||
Component::Prefix(_) => out.push(c),
|
||||
Component::RootDir => {
|
||||
rooted = true;
|
||||
out.push(c);
|
||||
}
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => match out.last() {
|
||||
Some(Component::Normal(_)) => {
|
||||
out.pop();
|
||||
}
|
||||
// `/..` reste `/`.
|
||||
_ if rooted => {}
|
||||
_ => out.push(c),
|
||||
},
|
||||
Component::Normal(_) => out.push(c),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return PathBuf::from(".");
|
||||
}
|
||||
out.iter().collect()
|
||||
}
|
||||
|
||||
/// `os.UserHomeDir` : `$HOME` (`%USERPROFILE%` sous Windows).
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
|
||||
std::env::var_os(var)
|
||||
.filter(|h| !h.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// `resolveAgentPath` : chemin fourni par le modèle. Absolu → inchangé ;
|
||||
/// `~/x` → dans le home ; relatif → dans le dossier de travail `cwd`.
|
||||
pub fn resolve_agent_path(cwd: &Path, p: &str) -> PathBuf {
|
||||
let p = p.trim();
|
||||
if p.is_empty() {
|
||||
return PathBuf::new();
|
||||
}
|
||||
if (p == "~" || p.starts_with("~/") || p.starts_with("~\\"))
|
||||
&& let Some(home) = home_dir()
|
||||
{
|
||||
let rest = &p[1..];
|
||||
let rest = rest.strip_prefix('/').unwrap_or(rest);
|
||||
return clean(&home.join(from_slash(rest)));
|
||||
}
|
||||
let path = Path::new(p);
|
||||
if path.is_absolute() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
clean(&cwd.join(from_slash(p)))
|
||||
}
|
||||
|
||||
/// `filepath.FromSlash`.
|
||||
fn from_slash(p: &str) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(p.replace('/', "\\"))
|
||||
} else {
|
||||
PathBuf::from(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// `filepath.ToSlash`.
|
||||
pub fn to_slash(p: &Path) -> String {
|
||||
let s = p.to_string_lossy();
|
||||
if cfg!(windows) {
|
||||
s.replace('\\', "/")
|
||||
} else {
|
||||
s.into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// `filepath.EvalSymlinks`, à défaut le chemin tel quel.
|
||||
fn eval_symlinks(p: &Path) -> PathBuf {
|
||||
match std::fs::canonicalize(p) {
|
||||
Ok(c) => strip_verbatim(c),
|
||||
Err(_) => p.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `canonicalize` rend `\\?\C:\…` sous Windows ; `EvalSymlinks` non.
|
||||
fn strip_verbatim(p: PathBuf) -> PathBuf {
|
||||
if cfg!(windows)
|
||||
&& let Some(s) = p.to_str()
|
||||
&& let Some(rest) = s.strip_prefix(r"\\?\")
|
||||
&& !rest.starts_with("UNC\\")
|
||||
{
|
||||
return PathBuf::from(rest);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// `p` est `root` lui-même ou dedans (lexicalement, chemins nettoyés).
|
||||
fn within(root: &Path, p: &Path) -> bool {
|
||||
clean(p).starts_with(clean(root))
|
||||
}
|
||||
|
||||
/// `sameOrWithin` : liens symboliques résolus des deux côtés.
|
||||
fn same_or_within(root: &Path, p: &Path) -> bool {
|
||||
within(&eval_symlinks(root), &eval_symlinks(p))
|
||||
}
|
||||
|
||||
/// `codePathAllowed` : un chemin (déjà résolu) est-il DANS le dossier de la
|
||||
/// discussion `ws` ? Le fichier peut ne pas exister encore (write) : c'est son
|
||||
/// ancêtre existant le plus proche qui doit y être, liens résolus ; et le
|
||||
/// chemin final ne doit pas en ressortir par des `..` lexicaux.
|
||||
pub fn code_path_allowed(ws: &Path, abs: &Path) -> bool {
|
||||
if ws.as_os_str().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let abs = clean(abs);
|
||||
let mut probe = abs.clone();
|
||||
loop {
|
||||
if std::fs::metadata(&probe).is_ok() {
|
||||
break;
|
||||
}
|
||||
match probe.parent() {
|
||||
Some(parent) if parent != probe => probe = parent.to_path_buf(),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
same_or_within(ws, &probe) && within(ws, &abs)
|
||||
}
|
||||
|
||||
/// `refusedPathResult` : refus d'un chemin hors du bac à sable du mode Code.
|
||||
pub fn refused_path_result(path: &str) -> String {
|
||||
format!(
|
||||
"[refusé] {path} est hors du dossier de cette discussion. \
|
||||
En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
)
|
||||
}
|
||||
|
||||
/// `dangerousPatterns` : motifs de commandes refusées, et pourquoi.
|
||||
/// Volontairement ÉTROIT : la perte de données irréversible et l'arrêt de la
|
||||
/// machine, pas la moindre écriture.
|
||||
static DANGEROUS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
|
||||
let root = "efface la racine du disque ou le home";
|
||||
let disk = "écrit directement sur un périphérique disque";
|
||||
let drive = "efface la racine d'un lecteur";
|
||||
[
|
||||
(
|
||||
r#"(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s*$"#,
|
||||
root,
|
||||
),
|
||||
(
|
||||
r#"(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s"#,
|
||||
root,
|
||||
),
|
||||
(r"(?i)\bmkfs(\.[a-z0-9]+)?\b", "formate un système de fichiers"),
|
||||
(r"(?i)\bdd\b[^\n]*\bof=/dev/", disk),
|
||||
(r"(?i)>\s*/dev/sd[a-z]\b", disk),
|
||||
(
|
||||
r"(?i)(^|[;&|]\s*|\bsudo\s+)(shutdown|poweroff|reboot|halt)\b",
|
||||
"arrête ou redémarre la machine",
|
||||
),
|
||||
(r":\(\)\s*\{\s*:\|:", "fork bomb"),
|
||||
(
|
||||
r"(?i)\bch(mod|own)\b[^\n]*-[a-z]*R[a-z]*\s+[^\s]+\s+/\s*$",
|
||||
"change récursivement les droits de la racine",
|
||||
),
|
||||
(r"(?i)\b(rd|rmdir)\s+/s\b[^\n]*\s[a-z]:\\?\s*$", drive),
|
||||
(r"(?i)\bdel\s+(/[fsq]\s+)*[a-z]:\\\*", drive),
|
||||
(r"(?i)\bformat\s+[a-z]:", "formate un lecteur"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(re, why)| (Regex::new(re).expect("motif de commande dangereuse"), why))
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// `dangerousCommand` : la raison du refus, `None` si la commande passe.
|
||||
pub fn dangerous_command(cmd: &str) -> Option<&'static str> {
|
||||
DANGEROUS
|
||||
.iter()
|
||||
.find(|(re, _)| re.is_match(cmd))
|
||||
.map(|(_, why)| *why)
|
||||
}
|
||||
|
||||
/// `refusedCommandResult` : dire POURQUOI et QUOI FAIRE, sinon le modèle
|
||||
/// réessaie en boucle avec des variantes.
|
||||
pub fn refused_command_result(reason: &str) -> String {
|
||||
format!(
|
||||
"[refusé] Commande bloquée par la politique de sécurité : {reason}. \
|
||||
Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal."
|
||||
)
|
||||
}
|
||||
|
||||
/// `normPath` : absolu, nettoyé, en minuscules sous Windows.
|
||||
fn norm_path(p: &Path) -> String {
|
||||
let abs = if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_default().join(p)
|
||||
};
|
||||
let s = clean(&abs).to_string_lossy().into_owned();
|
||||
if cfg!(windows) { s.to_lowercase() } else { s }
|
||||
}
|
||||
|
||||
/// `underDir` : `path` est `dir` ou dessous.
|
||||
fn under_dir(path: &Path, dir: &Path) -> bool {
|
||||
let (a, b) = (norm_path(path), norm_path(dir));
|
||||
a == b || a.starts_with(&format!("{b}{}", std::path::MAIN_SEPARATOR))
|
||||
}
|
||||
|
||||
const TOOL_ONLY_LABEL: &str = "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)";
|
||||
|
||||
/// `guardToolOnlyPath` : refus d'un write/edit dans le dossier mémoire.
|
||||
pub fn guard_tool_only_path(path: &Path, memory_dir: &Path) -> Option<String> {
|
||||
if memory_dir.as_os_str().is_empty() || !under_dir(path, memory_dir) {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"[refusé] pas d'accès direct au dossier {}. Utilise {TOOL_ONLY_LABEL}.",
|
||||
memory_dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// `guardToolOnlyCommand` : refus d'une commande qui MENTIONNE le dossier
|
||||
/// mémoire, lecture comprise.
|
||||
pub fn guard_tool_only_command(command: &str, memory_dir: &Path) -> Option<String> {
|
||||
if memory_dir.as_os_str().is_empty()
|
||||
|| !command
|
||||
.to_lowercase()
|
||||
.contains(&norm_path(memory_dir).to_lowercase())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"[refusé] pas d'accès direct au dossier {} via le shell. Utilise {TOOL_ONLY_LABEL}.",
|
||||
memory_dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nettoyage_comme_filepath_clean() {
|
||||
for (input, want) in [
|
||||
("", "."),
|
||||
("a/./b", "a/b"),
|
||||
("a/../..", ".."),
|
||||
("/../a", "/a"),
|
||||
("a//b/", "a/b"),
|
||||
("../../x/../y", "../../y"),
|
||||
("/", "/"),
|
||||
] {
|
||||
assert_eq!(clean(Path::new(input)), PathBuf::from(want), "{input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn chemins_de_l_agent() {
|
||||
let cwd = Path::new("/w/conv");
|
||||
assert_eq!(
|
||||
resolve_agent_path(cwd, " a/../b.txt "),
|
||||
PathBuf::from("/w/conv/b.txt")
|
||||
);
|
||||
assert_eq!(resolve_agent_path(cwd, "/etc/x"), PathBuf::from("/etc/x"));
|
||||
assert_eq!(resolve_agent_path(cwd, ""), PathBuf::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commandes_dangereuses() {
|
||||
for c in [
|
||||
"rm -rf /",
|
||||
"sudo rm -fr ~ ",
|
||||
"mkfs.ext4 /dev/sda1",
|
||||
"dd if=x of=/dev/sda",
|
||||
"ls; reboot",
|
||||
":(){ :|:& };:",
|
||||
"format c:",
|
||||
] {
|
||||
assert!(dangerous_command(c).is_some(), "{c}");
|
||||
}
|
||||
for c in [
|
||||
"rm -rf build",
|
||||
"grep reboot log.txt",
|
||||
"dd if=a of=b",
|
||||
"echo format",
|
||||
] {
|
||||
assert!(dangerous_command(c).is_none(), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bac_a_sable_du_mode_code() {
|
||||
let ws = tempfile::tempdir().unwrap();
|
||||
let w = ws.path();
|
||||
std::fs::create_dir(w.join("src")).unwrap();
|
||||
assert!(code_path_allowed(w, &w.join("src/neuf/a.rs")));
|
||||
assert!(code_path_allowed(w, w));
|
||||
assert!(!code_path_allowed(w, &w.join("../dehors")));
|
||||
assert!(!code_path_allowed(w, Path::new("/etc/passwd")));
|
||||
assert!(!code_path_allowed(Path::new(""), &w.join("a")));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink("/etc", w.join("lien")).unwrap();
|
||||
assert!(!code_path_allowed(w, &w.join("lien/passwd")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dossier_memoire_reserve() {
|
||||
let mem = std::env::temp_dir().join("loki-mem-test");
|
||||
assert!(guard_tool_only_path(&mem.join("a.md"), &mem).is_some());
|
||||
assert!(guard_tool_only_path(&mem, &mem).is_some());
|
||||
assert!(guard_tool_only_path(&std::env::temp_dir().join("loki-mem-testx"), &mem).is_none());
|
||||
let cmd = format!("cat {}/a.md", mem.display());
|
||||
assert!(guard_tool_only_command(&cmd, &mem).is_some());
|
||||
assert!(guard_tool_only_command("ls", &mem).is_none());
|
||||
assert!(guard_tool_only_command("ls", Path::new("")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Le shell de l'agent — port de `runShell` (`chat_tools.go`) et de
|
||||
//! `chat_shell_hygiene.go`.
|
||||
//!
|
||||
//! La commande part dans `/bin/bash -c` (`cmd /C` sous Windows), dans le
|
||||
//! dossier de la discussion, délai borné. Ce qu'elle rend au modèle est
|
||||
//! nettoyé : « | tail -N » final retiré (le code de sortie serait celui de
|
||||
//! tail) puis appliqué ici, séquences ANSI retirées, sortie bornée à sa fin.
|
||||
//!
|
||||
//! L'arrêt du tour abandonne le futur : le process est tué (`kill_on_drop`).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use regex::Regex;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use super::policy::guard_tool_only_command;
|
||||
|
||||
pub const TOOL_DEFAULT_TIMEOUT: i64 = 30;
|
||||
pub const TOOL_MAX_TIMEOUT: i64 = 300;
|
||||
/// Caractères de stdout/stderr rendus au modèle.
|
||||
pub const TOOL_MAX_OUTPUT: usize = 8000;
|
||||
/// `WaitDelay` : attente bornée des tubes après la fin du process — un
|
||||
/// « ./serveur & » laisse un petit-enfant accroché aux tubes, et le tour ne
|
||||
/// rendrait jamais la main.
|
||||
const WAIT_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
/// « | tail », « | tail -20 », « | tail -n 20 », « | tail -n20 » en fin.
|
||||
static TAIL_PIPE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"\s*\|\s*tail(?:\s+-n\s*([0-9]+)|\s+-([0-9]+))?\s*$").expect("motif tail")
|
||||
});
|
||||
|
||||
/// `splitTailPipe` : la commande sans son « | tail -N » final, et N (0 : pas
|
||||
/// de tail ; 10 : tail sans nombre).
|
||||
pub fn split_tail_pipe(command: &str) -> (String, usize) {
|
||||
let Some(c) = TAIL_PIPE.captures(command) else {
|
||||
return (command.to_string(), 0);
|
||||
};
|
||||
let n = [1, 2]
|
||||
.into_iter()
|
||||
.filter_map(|g| c.get(g))
|
||||
.filter_map(|m| m.as_str().parse::<usize>().ok())
|
||||
.find(|v| *v > 0)
|
||||
.unwrap_or(10);
|
||||
let rest = command[..c.get(0).map_or(0, |m| m.start())].trim();
|
||||
if rest.is_empty() {
|
||||
return (command.to_string(), 0);
|
||||
}
|
||||
(rest.to_string(), n)
|
||||
}
|
||||
|
||||
/// Séquences CSI (couleurs, curseur) et OSC (titres de fenêtre).
|
||||
static ANSI: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)").expect("motif ANSI")
|
||||
});
|
||||
|
||||
pub fn strip_ansi(s: &str) -> String {
|
||||
if !s.contains('\x1b') {
|
||||
return s.to_string();
|
||||
}
|
||||
ANSI.replace_all(s, "").into_owned()
|
||||
}
|
||||
|
||||
/// `trailingBackground` : la commande finit par un « & » (pas « && »).
|
||||
pub fn trailing_background(command: &str) -> bool {
|
||||
let c = command.trim();
|
||||
c.ends_with('&') && !c.ends_with("&&")
|
||||
}
|
||||
|
||||
/// `tailLines` : les n dernières lignes.
|
||||
pub fn tail_lines(s: &str, n: usize) -> String {
|
||||
let lines: Vec<&str> = s.trim_end_matches('\n').split('\n').collect();
|
||||
lines[lines.len().saturating_sub(n)..].join("\n")
|
||||
}
|
||||
|
||||
/// `tailOutput` : la FIN d'une sortie (`TOOL_MAX_OUTPUT` caractères), avec ce
|
||||
/// qui a été coupé dit en clair.
|
||||
pub fn tail_output(s: &str) -> String {
|
||||
let n = s.chars().count();
|
||||
if n <= TOOL_MAX_OUTPUT {
|
||||
return s.to_string();
|
||||
}
|
||||
let start = s
|
||||
.char_indices()
|
||||
.nth(n - TOOL_MAX_OUTPUT)
|
||||
.map_or(0, |(i, _)| i);
|
||||
format!(
|
||||
"[sortie tronquée : {n} caractères au total, seuls les {TOOL_MAX_OUTPUT} derniers sont gardés]\n{}",
|
||||
&s[start..]
|
||||
)
|
||||
}
|
||||
|
||||
/// `time.Duration.String()` d'une durée arrondie à 100 ms (« 0s », « 300ms »,
|
||||
/// « 1.2s », « 1m5s », « 1h0m0s »).
|
||||
pub fn go_duration(d: Duration) -> String {
|
||||
let ms = ((d.as_millis() + 50) / 100 * 100) as u64;
|
||||
if ms == 0 {
|
||||
return "0s".into();
|
||||
}
|
||||
if ms < 1000 {
|
||||
return format!("{ms}ms");
|
||||
}
|
||||
let (h, rem) = (ms / 3_600_000, ms % 3_600_000);
|
||||
let (m, rem) = (rem / 60_000, rem % 60_000);
|
||||
let (s, tenth) = (rem / 1000, rem % 1000 / 100);
|
||||
let secs = if tenth > 0 {
|
||||
format!("{s}.{tenth}s")
|
||||
} else {
|
||||
format!("{s}s")
|
||||
};
|
||||
match (h, m) {
|
||||
(0, 0) => secs,
|
||||
(0, m) => format!("{m}m{secs}"),
|
||||
(h, m) => format!("{h}h{m}m{secs}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lit un tube jusqu'au bout dans un tampon partagé (lisible même si la
|
||||
/// lecture est abandonnée en route).
|
||||
fn drain<R: tokio::io::AsyncRead + Unpin + Send + 'static>(
|
||||
mut r: R,
|
||||
) -> (Arc<Mutex<Vec<u8>>>, tokio::task::JoinHandle<()>) {
|
||||
let buf = Arc::new(Mutex::new(Vec::new()));
|
||||
let b = buf.clone();
|
||||
let h = tokio::spawn(async move {
|
||||
let mut chunk = [0u8; 8192];
|
||||
while let Ok(n) = r.read(&mut chunk).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
b.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
});
|
||||
(buf, h)
|
||||
}
|
||||
|
||||
/// La commande du shell de la plateforme. Le jeton de l'API interne n'a rien
|
||||
/// à faire dans le shell de l'agent.
|
||||
fn shell_cmd(command: &str) -> tokio::process::Command {
|
||||
#[cfg(windows)]
|
||||
let mut c = {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.arg("/C").arg(command);
|
||||
c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW : pas de flash de console
|
||||
c
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let mut c = {
|
||||
let mut c = tokio::process::Command::new("/bin/bash");
|
||||
c.arg("-c").arg(command);
|
||||
c
|
||||
};
|
||||
c.env_remove(crate::auth::INTERNAL_ENV);
|
||||
c
|
||||
}
|
||||
|
||||
/// `runShell` : « exit: N · durée », puis stdout et stderr nettoyés.
|
||||
pub async fn run_shell(cwd: &Path, memory_dir: &Path, command: &str, timeout_sec: i64) -> String {
|
||||
if let Some(msg) = guard_tool_only_command(command, memory_dir) {
|
||||
return msg;
|
||||
}
|
||||
let timeout_sec = match timeout_sec {
|
||||
t if t <= 0 => TOOL_DEFAULT_TIMEOUT,
|
||||
t => t.min(TOOL_MAX_TIMEOUT),
|
||||
};
|
||||
let (command, keep_lines) = split_tail_pipe(command);
|
||||
let start = Instant::now();
|
||||
let mut cmd = shell_cmd(&command);
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
// Dans le dossier de la discussion, recréé au besoin (l'utilisateur ou le
|
||||
// modèle a pu le supprimer) ; à défaut, là où on peut.
|
||||
if !cwd.as_os_str().is_empty() && std::fs::create_dir_all(cwd).is_ok() {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return format!("[erreur: {e}]"),
|
||||
};
|
||||
let (out_buf, out_h) = drain(child.stdout.take().expect("stdout"));
|
||||
let (err_buf, err_h) = drain(child.stderr.take().expect("stderr"));
|
||||
let deadline = Duration::from_secs(timeout_sec as u64);
|
||||
let (status, timed_out) = match tokio::time::timeout(deadline, child.wait()).await {
|
||||
Ok(st) => (st.ok(), false),
|
||||
Err(_) => {
|
||||
let _ = child.start_kill();
|
||||
(child.wait().await.ok(), true)
|
||||
}
|
||||
};
|
||||
// Tubes encore tenus par un petit-enfant : WAIT_DELAY, puis on lâche.
|
||||
let pipes_closed = tokio::time::timeout(WAIT_DELAY, async {
|
||||
let _ = out_h.await;
|
||||
let _ = err_h.await;
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
let take = |b: &Arc<Mutex<Vec<u8>>>| {
|
||||
String::from_utf8_lossy(&b.lock().unwrap_or_else(|e| e.into_inner())).into_owned()
|
||||
};
|
||||
let clean = |s: &str| tail_output(&strip_ansi(s));
|
||||
let mut out = take(&out_buf);
|
||||
if keep_lines > 0 {
|
||||
out = tail_lines(&out, keep_lines);
|
||||
}
|
||||
let out = clean(&out);
|
||||
let err_out = clean(&take(&err_buf));
|
||||
if timed_out {
|
||||
let mut msg = format!(
|
||||
"[timeout après {timeout_sec}s — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]"
|
||||
);
|
||||
if !out.is_empty() {
|
||||
msg.push_str(&format!("\n\nstdout (partiel):\n{out}"));
|
||||
}
|
||||
if !err_out.is_empty() {
|
||||
msg.push_str(&format!("\n\nstderr (partiel):\n{err_out}"));
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
let exit = match status {
|
||||
Some(st) => st.code().unwrap_or(-1),
|
||||
None => return "[erreur: attente du process impossible]".into(),
|
||||
};
|
||||
// Go : sortie 0 mais tubes jamais fermés → ErrWaitDelay.
|
||||
if exit == 0 && !pipes_closed {
|
||||
return "[erreur: exec: WaitDelay expired before I/O complete]".into();
|
||||
}
|
||||
let mut parts = vec![format!("exit: {exit} · {}", go_duration(start.elapsed()))];
|
||||
if !out.is_empty() {
|
||||
parts.push(format!("stdout:\n{out}"));
|
||||
}
|
||||
if !err_out.is_empty() {
|
||||
parts.push(format!("stderr:\n{err_out}"));
|
||||
}
|
||||
parts.join("\n\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tail_final_retire() {
|
||||
assert_eq!(split_tail_pipe("make | tail -20"), ("make".into(), 20));
|
||||
assert_eq!(split_tail_pipe("make | tail -n 5 "), ("make".into(), 5));
|
||||
assert_eq!(split_tail_pipe("make|tail"), ("make".into(), 10));
|
||||
assert_eq!(split_tail_pipe("| tail -3"), ("| tail -3".into(), 0));
|
||||
assert_eq!(split_tail_pipe("tail -f x"), ("tail -f x".into(), 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_et_arriere_plan() {
|
||||
assert_eq!(
|
||||
strip_ansi("\x1b[31mrouge\x1b[0m \x1b]0;titre\x07ok"),
|
||||
"rouge ok"
|
||||
);
|
||||
assert!(trailing_background("serveur &") && !trailing_background("a && b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durees_comme_go() {
|
||||
for (ms, want) in [
|
||||
(0, "0s"),
|
||||
(49, "0s"),
|
||||
(50, "100ms"),
|
||||
(340, "300ms"),
|
||||
(1_250, "1.3s"),
|
||||
(12_000, "12s"),
|
||||
(65_400, "1m5.4s"),
|
||||
(3_600_000, "1h0m0s"),
|
||||
] {
|
||||
assert_eq!(go_duration(Duration::from_millis(ms)), want, "{ms}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sortie_bornee() {
|
||||
let s = "x".repeat(TOOL_MAX_OUTPUT + 5);
|
||||
let t = tail_output(&s);
|
||||
assert!(t.starts_with("[sortie tronquée : 8005 caractères au total"));
|
||||
assert_eq!(tail_lines("a\nb\nc\n", 2), "b\nc");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn commande_et_delai() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let mem = d.path().join("memory");
|
||||
let r = run_shell(d.path(), &mem, "echo un; echo deux >&2; exit 3", 5).await;
|
||||
assert!(r.starts_with("exit: 3 · "), "{r}");
|
||||
assert!(r.ends_with("stdout:\nun\n\n\nstderr:\ndeux\n"), "{r:?}");
|
||||
let r = run_shell(d.path(), &mem, "printf 'a\\nb\\nc\\n' | tail -2", 5).await;
|
||||
assert!(r.ends_with("stdout:\nb\nc"), "{r:?}");
|
||||
let r = run_shell(d.path(), &mem, "pwd", 5).await;
|
||||
assert!(r.contains(&d.path().display().to_string()), "{r}");
|
||||
let r = run_shell(d.path(), &mem, "echo avant; sleep 5", 1).await;
|
||||
assert!(r.starts_with("[timeout après 1s"), "{r}");
|
||||
assert!(r.contains("stdout (partiel):\navant"), "{r}");
|
||||
let r = run_shell(d.path(), &mem, &format!("ls {}", mem.display()), 5).await;
|
||||
assert!(r.starts_with("[refusé] pas d'accès direct"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jeton_interne_absent_du_shell() {
|
||||
let c = shell_cmd("env");
|
||||
assert!(
|
||||
c.as_std()
|
||||
.get_envs()
|
||||
.any(|(k, v)| k == crate::auth::INTERNAL_ENV && v.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,36 @@ pub struct FakeGo {
|
||||
pub oai_log: Mutex<Vec<String>>,
|
||||
/// Mémoire verrouillée : seal/open répondent 423.
|
||||
pub locked: Mutex<bool>,
|
||||
/// Bail du moteur : bail courant, stop signalé, détenteur Go, journal.
|
||||
pub lease: Mutex<Option<String>>,
|
||||
pub lease_stop: Mutex<bool>,
|
||||
pub go_busy: Mutex<Option<String>>,
|
||||
pub lease_log: Mutex<Vec<String>>,
|
||||
/// Résultat imposé à tout outil (sinon « exec:<nom> »), et archive recall.
|
||||
pub tool_result: Mutex<Option<String>>,
|
||||
pub recall: Mutex<Vec<String>>,
|
||||
/// Discussion active annoncée et discussions supprimées.
|
||||
pub conv_log: Mutex<Vec<String>>,
|
||||
/// Tours : débuts, vues et fins demandés.
|
||||
pub turn_log: Mutex<Vec<String>>,
|
||||
/// Adresse d'écoute du faux Go (pour un client `Store` direct).
|
||||
pub backend: Mutex<Option<SocketAddr>>,
|
||||
/// Contexte des outils exécutés par la façade (None : 404, le Go les
|
||||
/// exécute), et journal du suivi (lectures, écritures, contrôles).
|
||||
pub tool_ctx: Mutex<Option<serde_json::Value>>,
|
||||
pub tracker_log: Mutex<Vec<String>>,
|
||||
/// Tours en mode Code, et outils exécutés (« nom rôle »). L'outil
|
||||
/// criteria (action=set) modifie les critères rangés (chat/crit:*).
|
||||
pub code: Mutex<bool>,
|
||||
pub tool_log: Mutex<Vec<String>>,
|
||||
/// Le Go garde la conversation (raison), sinon la façade la tient.
|
||||
pub go_keeps_chat: Mutex<Option<String>>,
|
||||
/// Réponses imposées de /turn/start et /turn/view (journaux de référence
|
||||
/// du Go), et résultat par outil.
|
||||
pub turn_start: Mutex<Option<serde_json::Value>>,
|
||||
pub turn_view: Mutex<Option<serde_json::Value>>,
|
||||
pub turn_verifier: Mutex<Option<serde_json::Value>>,
|
||||
pub tool_results: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl FakeGo {
|
||||
@@ -90,6 +118,14 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
let path = req.uri().path().to_string();
|
||||
if !path.starts_with("/internal/") {
|
||||
st.relayed.lock().unwrap().push(path.clone());
|
||||
if path == "/api/chat/mode" && req.method() == Method::POST {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
return resp(
|
||||
200,
|
||||
serde_json::json!({"ok": true, "mode": v["mode"]}).to_string(),
|
||||
);
|
||||
}
|
||||
return resp(200, format!("go:{path}"));
|
||||
}
|
||||
if req.headers().get("x-loki-internal").map(|v| v.as_bytes()) != Some(JETON.as_bytes()) {
|
||||
@@ -118,12 +154,191 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
resp(200, body.strip_prefix(b"SCELLE:").unwrap_or(&body).to_vec())
|
||||
}
|
||||
}
|
||||
"/internal/v1/engine/lease" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
if let Some(why) = st.go_busy.lock().unwrap().clone() {
|
||||
let out = serde_json::json!({"error": why, "task_id": "t1", "task_name": "veille"});
|
||||
return resp(409, out.to_string());
|
||||
}
|
||||
let mut lease = st.lease.lock().unwrap();
|
||||
if lease.is_some() {
|
||||
return resp(409, r#"{"error":"génération en cours"}"#);
|
||||
}
|
||||
let n = st.lease_log.lock().unwrap().len();
|
||||
let id = format!("bail{n}");
|
||||
*lease = Some(id.clone());
|
||||
st.lease_log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("acquire {}", v["ttl_ms"]));
|
||||
resp(
|
||||
200,
|
||||
serde_json::json!({"lease": id, "ttl_ms": v["ttl_ms"]}).to_string(),
|
||||
)
|
||||
}
|
||||
"/internal/v1/engine/lease/renew" | "/internal/v1/engine/lease/release" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let id = v["lease"].as_str().unwrap_or_default().to_string();
|
||||
let mut lease = st.lease.lock().unwrap();
|
||||
let held = lease.as_deref() == Some(id.as_str());
|
||||
if path.ends_with("release") {
|
||||
st.lease_log.lock().unwrap().push(format!("release {id}"));
|
||||
if held {
|
||||
*lease = None;
|
||||
}
|
||||
return resp(204, "");
|
||||
}
|
||||
st.lease_log.lock().unwrap().push(format!("renew {id}"));
|
||||
if !held {
|
||||
return resp(410, r#"{"error":"bail perdu"}"#);
|
||||
}
|
||||
let stop = *st.lease_stop.lock().unwrap();
|
||||
resp(200, serde_json::json!({"stop": stop}).to_string())
|
||||
}
|
||||
"/internal/v1/recall/archive" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let mut r = st.recall.lock().unwrap();
|
||||
r.push(v["content"].as_str().unwrap_or_default().to_string());
|
||||
resp(
|
||||
200,
|
||||
serde_json::json!({"id": format!("r{}", r.len())}).to_string(),
|
||||
)
|
||||
}
|
||||
"/internal/v1/conv/active" | "/internal/v1/conv/dropped" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let what = path.rsplit('/').next().unwrap();
|
||||
st.conv_log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("{what} {}", v["id"].as_str().unwrap_or_default()));
|
||||
resp(204, "")
|
||||
}
|
||||
"/internal/v1/turn/start" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let msg = v["message"].as_str().unwrap_or_default().to_string();
|
||||
st.turn_log.lock().unwrap().push(format!("start {msg}"));
|
||||
if msg.trim().is_empty() {
|
||||
return resp(400, r#"{"error":"message vide"}"#);
|
||||
}
|
||||
if let Some(v) = st.turn_start.lock().unwrap().clone() {
|
||||
return resp(200, v.to_string());
|
||||
}
|
||||
let code = *st.code.lock().unwrap();
|
||||
let caps = serde_json::json!({"Agent": true, "Mem": "off", "Code": code, "Role": ""});
|
||||
let out = serde_json::json!({
|
||||
"files": [], "caps": caps, "turn_caps": caps,
|
||||
"content": msg, "model": "m1", "code_hint": false,
|
||||
});
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/turn/view" => {
|
||||
st.turn_log.lock().unwrap().push("view".into());
|
||||
if let Some(v) = st.turn_view.lock().unwrap().clone() {
|
||||
return resp(200, v.to_string());
|
||||
}
|
||||
let out = serde_json::json!({
|
||||
"head": [{"role": "system", "content": "Tu es Loki."}],
|
||||
"prefix": "PRÉAMBULE",
|
||||
"tools": [{"type": "function", "function": {"name": "bash", "description": "", "parameters": {"type": "object"}}}],
|
||||
});
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/chat/holder" => match st.go_keeps_chat.lock().unwrap().clone() {
|
||||
None => resp(200, r#"{"holder":"rust"}"#),
|
||||
Some(why) => resp(
|
||||
200,
|
||||
serde_json::json!({"holder": "go", "reason": why}).to_string(),
|
||||
),
|
||||
},
|
||||
"/internal/v1/project/activate" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let slug = v["slug"].as_str().unwrap_or_default().to_string();
|
||||
if slug.is_empty() || slug == "absent" {
|
||||
return resp(400, r#"{"error":"projet introuvable"}"#);
|
||||
}
|
||||
st.conv_log.lock().unwrap().push(format!("project {slug}"));
|
||||
st.set("state", "active_project", &slug);
|
||||
resp(204, "")
|
||||
}
|
||||
"/internal/v1/turn/verifier" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
st.turn_log.lock().unwrap().push("verifier".into());
|
||||
if let Some(out) = st.turn_verifier.lock().unwrap().clone() {
|
||||
return resp(200, out.to_string());
|
||||
}
|
||||
let mut caps = v["caps"].clone();
|
||||
caps["Role"] = "verifier".into();
|
||||
let out = serde_json::json!({
|
||||
"system": "VÉRIFICATEUR",
|
||||
"tools": [{"type": "function", "function": {"name": "criteria", "description": "", "parameters": {"type": "object"}}}],
|
||||
"caps": caps,
|
||||
});
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/turn/done" => {
|
||||
st.turn_log.lock().unwrap().push("done".into());
|
||||
resp(204, "")
|
||||
}
|
||||
"/internal/v1/engine/stop" => {
|
||||
st.lease_log.lock().unwrap().push("stop".into());
|
||||
*st.go_busy.lock().unwrap() = None;
|
||||
resp(204, "")
|
||||
}
|
||||
"/internal/v1/tool/context" => match st.tool_ctx.lock().unwrap().clone() {
|
||||
Some(v) => resp(200, v.to_string()),
|
||||
None => resp(404, "404 page not found"),
|
||||
},
|
||||
"/internal/v1/tool/tracker" | "/internal/v1/tool/wrote" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let op = v["op"].as_str().unwrap_or("wrote").to_string();
|
||||
let path = v["path"].as_str().unwrap_or("").to_string();
|
||||
st.tracker_log.lock().unwrap().push(format!("{op} {path}"));
|
||||
let out = if op == "check" {
|
||||
serde_json::json!({ "refusal": "" })
|
||||
} else {
|
||||
serde_json::json!({ "lsp": "" })
|
||||
};
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/tool/exec" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let name = v["name"].as_str().unwrap_or_default();
|
||||
st.tool_log.lock().unwrap().push(format!(
|
||||
"{name} {}",
|
||||
v["caps"]["Role"].as_str().unwrap_or_default()
|
||||
));
|
||||
if name == "criteria" && v["args"]["action"] == "set" {
|
||||
let mut chat = st.store.lock().unwrap();
|
||||
for (k, val) in chat.entry("chat".into()).or_default().iter_mut() {
|
||||
if !k.starts_with("crit:") {
|
||||
continue;
|
||||
}
|
||||
let mut list: Vec<serde_json::Value> =
|
||||
serde_json::from_slice(val).unwrap_or_default();
|
||||
for c in &mut list {
|
||||
if c["id"] == v["args"]["id"] {
|
||||
c["status"] = v["args"]["status"].clone();
|
||||
}
|
||||
}
|
||||
*val = serde_json::to_vec(&list).unwrap();
|
||||
}
|
||||
}
|
||||
let by_name = st.tool_results.lock().unwrap().get(name).cloned();
|
||||
let result = by_name
|
||||
.or_else(|| st.tool_result.lock().unwrap().clone())
|
||||
.unwrap_or_else(|| format!("exec:{}", v["name"].as_str().unwrap()));
|
||||
let out = serde_json::json!({
|
||||
"label": v["args"]["command"],
|
||||
"result": format!("exec:{}", v["name"].as_str().unwrap()),
|
||||
"result": result,
|
||||
"caps_agent": v["caps"]["Agent"],
|
||||
});
|
||||
resp(200, out.to_string())
|
||||
@@ -212,7 +427,39 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
/// Lance le faux Go puis la façade devant lui ; rend (adresse de la façade,
|
||||
/// état du faux Go, garde d'arrêt).
|
||||
pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
|
||||
let (f, st, tx, _) = start_opts(home, false, None).await;
|
||||
(f, st, tx)
|
||||
}
|
||||
|
||||
/// Comme [`start`], avec la conversation tenue par la façade
|
||||
/// (`LOKI_CHAT=rust`) ; `engine_port` : port du faux moteur (config PORT).
|
||||
pub async fn start_chat(
|
||||
home: std::path::PathBuf,
|
||||
engine_port: u16,
|
||||
) -> (
|
||||
SocketAddr,
|
||||
Arc<FakeGo>,
|
||||
oneshot::Sender<()>,
|
||||
Arc<loki_rs::chat::generate::ChatService>,
|
||||
) {
|
||||
let (f, st, tx, chat) = start_opts(home, true, Some(engine_port)).await;
|
||||
(f, st, tx, chat.unwrap())
|
||||
}
|
||||
|
||||
async fn start_opts(
|
||||
home: std::path::PathBuf,
|
||||
with_chat: bool,
|
||||
engine_port: Option<u16>,
|
||||
) -> (
|
||||
SocketAddr,
|
||||
Arc<FakeGo>,
|
||||
oneshot::Sender<()>,
|
||||
Option<Arc<loki_rs::chat::generate::ChatService>>,
|
||||
) {
|
||||
let st = Arc::new(FakeGo::default());
|
||||
if let Some(p) = engine_port {
|
||||
st.set("config", "PORT", &p.to_string());
|
||||
}
|
||||
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend = l.local_addr().unwrap();
|
||||
*st.backend.lock().unwrap() = Some(backend);
|
||||
@@ -231,6 +478,22 @@ pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc<FakeGo>, onesho
|
||||
});
|
||||
}
|
||||
});
|
||||
let chat = if with_chat {
|
||||
use loki_rs::chat::{
|
||||
conv::Conversation, generate::ChatService, host::ChatShared, sessions,
|
||||
};
|
||||
let store = loki_rs::store::Store::new(backend, JETON.into());
|
||||
let s = sessions::Sessions::new(
|
||||
store.clone(),
|
||||
Conversation::new(),
|
||||
sessions::GoSessionHooks(store.clone()),
|
||||
);
|
||||
s.load().await.unwrap();
|
||||
let shared = ChatShared::for_sessions(&s, store, home.join("chatimg"));
|
||||
Some(ChatService::new(Arc::new(s), Arc::new(shared)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let fl = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let facade = fl.local_addr().unwrap();
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
@@ -242,11 +505,12 @@ pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc<FakeGo>, onesho
|
||||
home,
|
||||
service: "loki-engine".into(),
|
||||
},
|
||||
chat: chat.clone(),
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
|
||||
let _ = rx.await;
|
||||
}));
|
||||
(facade, st, tx)
|
||||
(facade, st, tx, chat)
|
||||
}
|
||||
|
||||
/// GET via la façade : (code, en-têtes, corps).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod fakego;
|
||||
pub mod toolsfx;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::Full;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Rejeu des journaux de référence des outils (`tests/parity_tools/*.json`,
|
||||
//! produits par `internal/loki/parity_tools_test.go`) dans
|
||||
//! `tools::exec_local`. Partagé par `tests/tools.rs` et `tests/tools_git.rs`.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use loki_rs::chat::stream::tool_call_label;
|
||||
use loki_rs::chat::types::{AskEvent, DiffLine};
|
||||
use loki_rs::tools::{ToolCtx, ToolState, exec_local};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GitCmd {
|
||||
dir: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FileFx {
|
||||
path: String,
|
||||
content: String,
|
||||
age: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CaseFx {
|
||||
name: String,
|
||||
tool: String,
|
||||
args: Map<String, Value>,
|
||||
#[serde(default)]
|
||||
code: bool,
|
||||
#[serde(default)]
|
||||
unix: bool,
|
||||
label: String,
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
diff: Vec<DiffLine>,
|
||||
#[serde(default)]
|
||||
added: i64,
|
||||
#[serde(default)]
|
||||
removed: i64,
|
||||
#[serde(default)]
|
||||
ask: Option<AskEvent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Fixture {
|
||||
cwd: String,
|
||||
memory_dir: String,
|
||||
files: Vec<FileFx>,
|
||||
#[serde(default)]
|
||||
git_setup: Vec<GitCmd>,
|
||||
#[serde(default)]
|
||||
edits: Vec<FileFx>,
|
||||
cases: Vec<CaseFx>,
|
||||
after: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Le suivi « lu avant d'écrire » du Go (`code_tracker.go`), en mémoire.
|
||||
pub struct FakeState {
|
||||
pub ctx: ToolCtx,
|
||||
pub reads: Mutex<HashMap<PathBuf, SystemTime>>,
|
||||
}
|
||||
|
||||
impl ToolState for FakeState {
|
||||
async fn context(&self) -> Result<ToolCtx, String> {
|
||||
Ok(self.ctx.clone())
|
||||
}
|
||||
|
||||
async fn note_read(&self, path: &Path) {
|
||||
self.reads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.to_path_buf(), SystemTime::now());
|
||||
}
|
||||
|
||||
async fn check(&self, path: &Path, _for_write: bool) -> Option<String> {
|
||||
let mtime = std::fs::metadata(path).ok()?.modified().ok()?;
|
||||
let p = path.display();
|
||||
match self.reads.lock().unwrap().get(path) {
|
||||
None => Some(format!(
|
||||
"[refusé] {p} existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
|
||||
)),
|
||||
Some(at) if mtime > *at => Some(format!(
|
||||
"[refusé] {p} a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier."
|
||||
)),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wrote(&self, path: &Path) -> String {
|
||||
self.note_read(path).await;
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn subst(v: &Value, root: &str, mem: &str) -> Value {
|
||||
match v {
|
||||
Value::String(s) => Value::String(s.replace("{{MEM}}", mem).replace("{{ROOT}}", root)),
|
||||
Value::Object(m) => Value::Object(
|
||||
m.iter()
|
||||
.map(|(k, e)| (k.clone(), subst(e, root, mem)))
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(a) => Value::Array(a.iter().map(|e| subst(e, root, mem)).collect()),
|
||||
_ => v.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn norm(s: &str, root: &str) -> String {
|
||||
static DUR: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
|
||||
Regex::new(r"(exit: -?\d+) · [0-9.]+[a-zµ]+(?:[0-9.]+[a-zµ]+)*").unwrap()
|
||||
});
|
||||
DUR.replace_all(&s.replace(root, "{{ROOT}}"), "$1 · {{DUR}}")
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn tree(root: &Path) -> BTreeMap<String, String> {
|
||||
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, String>) {
|
||||
for e in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
walk(root, &p, out);
|
||||
continue;
|
||||
}
|
||||
let rel = p
|
||||
.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if rel.contains("/.git/") {
|
||||
continue;
|
||||
}
|
||||
if rel.starts_with("ws/") || rel == "dehors.txt" || rel == "evade.txt" {
|
||||
out.insert(
|
||||
rel,
|
||||
String::from_utf8_lossy(&std::fs::read(&p).unwrap()).into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
walk(root, root, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Rejoue `tests/parity_tools/<name>.json` et rend les écarts avec le Go.
|
||||
pub async fn replay(name: &str) -> Vec<String> {
|
||||
let path =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("tests/parity_tools/{name}.json"));
|
||||
let fx: Fixture = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = std::fs::canonicalize(tmp.path()).unwrap();
|
||||
let root_s = root.display().to_string();
|
||||
let now = SystemTime::now();
|
||||
for f in &fx.files {
|
||||
let p = root.join(&f.path);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, &f.content).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&p)
|
||||
.unwrap()
|
||||
.set_modified(now - Duration::from_secs(f.age))
|
||||
.unwrap();
|
||||
}
|
||||
for g in &fx.git_setup {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(&g.args)
|
||||
.current_dir(root.join(&g.dir))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} : {}",
|
||||
g.args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
for f in &fx.edits {
|
||||
let p = root.join(&f.path);
|
||||
std::fs::write(&p, &f.content).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&p)
|
||||
.unwrap()
|
||||
.set_modified(now - Duration::from_secs(f.age))
|
||||
.unwrap();
|
||||
}
|
||||
let mem = root.join(&fx.memory_dir);
|
||||
let st = FakeState {
|
||||
ctx: ToolCtx {
|
||||
cwd: root.join(&fx.cwd),
|
||||
memory_dir: mem.clone(),
|
||||
target: String::new(),
|
||||
code: false,
|
||||
},
|
||||
reads: Mutex::new(HashMap::new()),
|
||||
};
|
||||
let mem_s = mem.display().to_string();
|
||||
let mut diffs = Vec::new();
|
||||
for c in &fx.cases {
|
||||
if c.unix && cfg!(windows) {
|
||||
continue;
|
||||
}
|
||||
let args = match subst(&Value::Object(c.args.clone()), &root_s, &mem_s) {
|
||||
Value::Object(m) => m,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let label = tool_call_label(&c.tool, &args);
|
||||
let out = exec_local(&st, c.code, &c.tool, &args, &label)
|
||||
.await
|
||||
.unwrap_or_else(|| panic!("{} : non exécuté ici", c.name));
|
||||
let got = json!({
|
||||
"label": norm(&label, &root_s),
|
||||
"result": norm(&out.result, &root_s),
|
||||
"diff": out.diff,
|
||||
"added": out.added,
|
||||
"removed": out.removed,
|
||||
"ask": out.ask,
|
||||
});
|
||||
let want = json!({
|
||||
"label": c.label,
|
||||
"result": c.result,
|
||||
"diff": c.diff,
|
||||
"added": c.added,
|
||||
"removed": c.removed,
|
||||
"ask": c.ask,
|
||||
});
|
||||
if got != want {
|
||||
diffs.push(format!("{} :\n obtenu {got}\n attendu {want}", c.name));
|
||||
}
|
||||
}
|
||||
let after = tree(&root);
|
||||
if after != fx.after {
|
||||
diffs.push(format!(
|
||||
"arborescence finale :\n obtenu {after:?}\n attendu {:?}",
|
||||
fx.after
|
||||
));
|
||||
}
|
||||
diffs
|
||||
}
|
||||
@@ -93,6 +93,7 @@ async fn banc(cle: Option<&str>) -> Banc {
|
||||
home: std::env::temp_dir(),
|
||||
service: "loki-engine".into(),
|
||||
},
|
||||
chat: None,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,284 @@
|
||||
//! Parité de la boucle d'un tour avec le Go (lot 6).
|
||||
//!
|
||||
//! `tests/parity/*.json` : journaux de référence produits par la boucle Go
|
||||
//! (`internal/loki/parity_test.go`, régénérés par
|
||||
//! `LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteJournaux`).
|
||||
//! Chaque scénario est rejoué ici dans `chat::turn::run_turn`, sur un hôte qui
|
||||
//! sert les mêmes réponses du moteur et les mêmes résultats d'outils. Doivent
|
||||
//! se retrouver à l'identique : les corps envoyés au moteur (le cache de
|
||||
//! préfixe en dépend), les événements d'affichage (texte regroupé, mesures
|
||||
//! ignorées), les messages ajoutés à l'historique et l'erreur.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use loki_rs::chat::compact::CompactConfig;
|
||||
use loki_rs::chat::stream::RetryPatterns;
|
||||
use loki_rs::chat::turn::{
|
||||
Cancel, CompactPhase, Reply, SendError, ToolOutcome, TurnConfig, TurnHost, run_turn,
|
||||
};
|
||||
use loki_rs::chat::types::{Message, StreamEvent, Tool};
|
||||
use loki_rs::chat::wire::{
|
||||
ChatEndpoint, EchoRefusals, EffortFallbacks, echo_policy_for, resolve_chat_endpoint,
|
||||
turn_reasoning,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReplyFx {
|
||||
status: u16,
|
||||
lines: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Expect {
|
||||
requests: Vec<Value>,
|
||||
events: Vec<Value>,
|
||||
extra: Value,
|
||||
err: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Fixture {
|
||||
name: String,
|
||||
cfg: HashMap<String, String>,
|
||||
messages: Vec<Message>,
|
||||
tools: Vec<Tool>,
|
||||
temperature: f64,
|
||||
replies: Vec<ReplyFx>,
|
||||
tool_results: HashMap<String, String>,
|
||||
expect: Expect,
|
||||
}
|
||||
|
||||
/// Hôte du rejeu : le moteur répond selon le script, les outils selon la
|
||||
/// table du scénario — exactement ce que voit la boucle Go.
|
||||
struct ParityHost {
|
||||
replies: Mutex<VecDeque<ReplyFx>>,
|
||||
tool_results: HashMap<String, String>,
|
||||
requests: Mutex<Vec<Value>>,
|
||||
}
|
||||
|
||||
impl TurnHost for ParityHost {
|
||||
async fn send(&self, _ep: &ChatEndpoint, body: Vec<u8>) -> Result<Reply, SendError> {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(serde_json::from_slice(&body).expect("corps JSON"));
|
||||
let r = self
|
||||
.replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("requête non scriptée");
|
||||
let chunks: Vec<Result<Bytes, String>> = r
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|l| Ok(Bytes::from(format!("{l}\n\n"))))
|
||||
.collect();
|
||||
Ok(Reply {
|
||||
status: r.status,
|
||||
body: Box::pin(futures_util::stream::iter(chunks)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn exec_tool(&self, name: &str, _args: &Map<String, Value>, _label: &str) -> ToolOutcome {
|
||||
ToolOutcome {
|
||||
result: self
|
||||
.tool_results
|
||||
.get(name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("[erreur] outil inconnu : {name}")),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn save_tool_result(&self, _result: &str) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
async fn compact(&self, _phase: CompactPhase, _msgs: &[Message]) -> Option<Vec<Message>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn drain_queued(&self) -> Vec<Message> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn image_data_url(&self, _r: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn effort_remember(&self, _want: &str, _got: &str) {}
|
||||
|
||||
fn echo_refuse(&self, _model: &str) {}
|
||||
}
|
||||
|
||||
/// Forme comparable d'un événement — `parityEvent` du Go (`None` : mesures).
|
||||
fn event(ev: &StreamEvent) -> Option<Value> {
|
||||
Some(match ev {
|
||||
StreamEvent::Content(s) if !s.is_empty() => json!({ "content": s }),
|
||||
StreamEvent::Reasoning(s) if !s.is_empty() => json!({ "reasoning": s }),
|
||||
StreamEvent::Content(_) | StreamEvent::Reasoning(_) | StreamEvent::Stats(_) => {
|
||||
return None;
|
||||
}
|
||||
StreamEvent::ToolUsed(t) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("name".into(), json!(t.name));
|
||||
let mut put = |k: &str, v: Value, keep: bool| {
|
||||
if keep {
|
||||
m.insert(k.into(), v);
|
||||
}
|
||||
};
|
||||
put("label", json!(t.label), !t.label.is_empty());
|
||||
put("result", json!(t.result), !t.result.is_empty());
|
||||
put("done", json!(true), t.done);
|
||||
put("typing", json!(true), t.typing);
|
||||
put("body", json!(t.body), !t.body.is_empty());
|
||||
put("body_tail", json!(true), t.body_tail);
|
||||
put("body_lines", json!(t.body_lines), t.body_lines != 0);
|
||||
put("diff", json!(t.diff), !t.diff.is_empty());
|
||||
put("added", json!(t.added), t.added != 0);
|
||||
put("removed", json!(t.removed), t.removed != 0);
|
||||
put("image", json!(t.image), !t.image.is_empty());
|
||||
put("shot", json!(t.shot), !t.shot.is_empty());
|
||||
put("result_chars", json!(t.result_chars), t.result_chars != 0);
|
||||
put("result_id", json!(t.result_id), !t.result_id.is_empty());
|
||||
json!({ "tool_used": m })
|
||||
}
|
||||
StreamEvent::Ask(a) => json!({ "ask": a }),
|
||||
StreamEvent::Err(e) => json!({ "err": e }),
|
||||
StreamEvent::DropReasoning => json!({ "drop_reasoning": true }),
|
||||
StreamEvent::Compacting(b) => json!({ "compacting": b }),
|
||||
StreamEvent::NewHistory(h) => json!({ "new_history": h }),
|
||||
StreamEvent::Echo(e) => json!({ "echo": { "text": e.text, "model": e.model } }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fragments de texte consécutifs regroupés — `parityMerge` du Go.
|
||||
fn merge(evs: Vec<Value>) -> Vec<Value> {
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
for e in evs {
|
||||
if let Some(last) = out.last_mut() {
|
||||
let (lo, eo) = (last.as_object().unwrap(), e.as_object().unwrap());
|
||||
if lo.len() == 1 && eo.len() == 1 {
|
||||
let k = ["content", "reasoning"].into_iter().find(|k| {
|
||||
lo.get(*k).is_some_and(Value::is_string)
|
||||
&& eo.get(*k).is_some_and(Value::is_string)
|
||||
});
|
||||
if let Some(k) = k {
|
||||
let s = format!("{}{}", lo[k].as_str().unwrap(), eo[k].as_str().unwrap());
|
||||
*last = json!({ k: s });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn replay(fx: Fixture) -> Result<(), String> {
|
||||
let cfg = fx.cfg;
|
||||
let refusals = EchoRefusals::default();
|
||||
let echo = echo_policy_for(&cfg, &refusals, None);
|
||||
let tc = TurnConfig {
|
||||
ep: resolve_chat_endpoint(&cfg, ""),
|
||||
tools: fx.tools,
|
||||
temperature: fx.temperature,
|
||||
code: false,
|
||||
reasoning: turn_reasoning(&cfg, &EffortFallbacks::default()),
|
||||
compact: CompactConfig::from_config(&cfg, echo.clone()),
|
||||
echo,
|
||||
patterns: RetryPatterns::builtin(),
|
||||
cfg,
|
||||
};
|
||||
let host = ParityHost {
|
||||
replies: Mutex::new(fx.replies.into()),
|
||||
tool_results: fx.tool_results,
|
||||
requests: Mutex::new(Vec::new()),
|
||||
};
|
||||
let evs = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = evs.clone();
|
||||
let res = run_turn(&host, &tc, fx.messages, &Cancel::never(), &mut |ev| {
|
||||
if let Some(e) = event(&ev) {
|
||||
sink.lock().unwrap().push(e);
|
||||
}
|
||||
true
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut diffs = Vec::new();
|
||||
let requests = host.requests.into_inner().unwrap();
|
||||
if requests.len() != fx.expect.requests.len() {
|
||||
diffs.push(format!(
|
||||
"requêtes : {} envoyée(s), {} attendue(s)",
|
||||
requests.len(),
|
||||
fx.expect.requests.len()
|
||||
));
|
||||
}
|
||||
for (i, (got, want)) in requests.iter().zip(&fx.expect.requests).enumerate() {
|
||||
if got != want {
|
||||
diffs.push(format!(
|
||||
"requête {} :\n obtenu {}\n attendu {}",
|
||||
i + 1,
|
||||
got,
|
||||
want
|
||||
));
|
||||
}
|
||||
}
|
||||
let events = merge(std::mem::take(&mut *evs.lock().unwrap()));
|
||||
if events != fx.expect.events {
|
||||
diffs.push(format!(
|
||||
"événements :\n obtenu {}\n attendu {}",
|
||||
Value::Array(events),
|
||||
Value::Array(fx.expect.events)
|
||||
));
|
||||
}
|
||||
let extra = serde_json::to_value(&res.extra).unwrap();
|
||||
if extra != fx.expect.extra {
|
||||
diffs.push(format!(
|
||||
"historique :\n obtenu {extra}\n attendu {}",
|
||||
fx.expect.extra
|
||||
));
|
||||
}
|
||||
let err = res.err.unwrap_or_default();
|
||||
if err != fx.expect.err {
|
||||
diffs.push(format!("erreur : {err:?}, attendu {:?}", fx.expect.err));
|
||||
}
|
||||
if diffs.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{} :\n{}", fx.name, diffs.join("\n")))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journaux_du_go() {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/parity");
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.expect("tests/parity")
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "json"))
|
||||
.collect();
|
||||
paths.sort();
|
||||
assert!(!paths.is_empty(), "aucun journal de référence");
|
||||
let mut failures = Vec::new();
|
||||
for p in &paths {
|
||||
let fx: Fixture = serde_json::from_slice(&std::fs::read(p).unwrap())
|
||||
.unwrap_or_else(|e| panic!("{} illisible : {e}", p.display()));
|
||||
if let Err(e) = replay(fx).await {
|
||||
failures.push(e);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{} scénario(s) sur {} divergent du Go :\n\n{}",
|
||||
failures.len(),
|
||||
paths.len(),
|
||||
failures.join("\n\n")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
{
|
||||
"name": "api_externe",
|
||||
"cfg": {
|
||||
"EXTERNAL": "1",
|
||||
"EXTERNAL_MODEL": "gpt-x",
|
||||
"EXTERNAL_URL": "{{URL}}"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Lis a.txt"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Je lis.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"file\\\":\\\"a.txt\\\"}\",\"name\":\"read\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Deux lignes.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "gpt-x",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"a.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "ligne 1\nligne 2",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"model": "gpt-x",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"reasoning": "Je lis."
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"result": "ligne 1\nligne 2",
|
||||
"result_chars": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Deux lignes."
|
||||
}
|
||||
],
|
||||
"extra": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"arguments": "{\"file\":\"a.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "ligne 1\nligne 2",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
{
|
||||
"name": "appel_repete",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Lis a.txt"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"file\\\":\\\"a.txt\\\"}\",\"name\":\"read\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"file\\\":\\\"a.txt\\\"}\",\"name\":\"read\"},\"id\":\"c2\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Toujours deux lignes.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"a.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "ligne 1\nligne 2",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"a.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "ligne 1\nligne 2",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"a.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c2",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "[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\nligne 1\nligne 2",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c2"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"result": "ligne 1\nligne 2",
|
||||
"result_chars": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"result": "[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\nligne 1\nligne 2",
|
||||
"result_chars": 155
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Toujours deux lignes."
|
||||
}
|
||||
],
|
||||
"extra": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"arguments": "{\"file\":\"a.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "ligne 1\nligne 2",
|
||||
"tool_call_id": "c1"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"arguments": "{\"file\":\"a.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "[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\nligne 1\nligne 2",
|
||||
"tool_call_id": "c2"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"name": "appel_textuel",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Lis a.txt"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"\\u003ctool_call\\u003e{\\\"name\\\":\\\"read\\\",\\\"arguments\\\":{\\\"file\\\":\\\"a.txt\\\"}}\\u003c/tool_call\\u003e\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"file\\\":\\\"a.txt\\\"}\",\"name\":\"read\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Deux lignes.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "<tool_call>{\"name\":\"read\",\"arguments\":{\"file\":\"a.txt\"}}</tool_call>",
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"content": "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n<tool_call>{\"name\":\"read\",\"arguments\":{\"file\":\"a.txt\"}}</tool_call>\nTool calls must go through the tool-call protocol, never in the answer text. Redo it now: emit the real tool call, or answer directly without pretending to call a tool.",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "<tool_call>{\"name\":\"read\",\"arguments\":{\"file\":\"a.txt\"}}</tool_call>",
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"content": "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n<tool_call>{\"name\":\"read\",\"arguments\":{\"file\":\"a.txt\"}}</tool_call>\nTool calls must go through the tool-call protocol, never in the answer text. Redo it now: emit the real tool call, or answer directly without pretending to call a tool.",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"a.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "ligne 1\nligne 2",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"content": "<tool_call>{\"name\":\"read\",\"arguments\":{\"file\":\"a.txt\"}}</tool_call>"
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"result": "ligne 1\nligne 2",
|
||||
"result_chars": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Deux lignes."
|
||||
}
|
||||
],
|
||||
"extra": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<tool_call>{\"name\":\"read\",\"arguments\":{\"file\":\"a.txt\"}}</tool_call>"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n<tool_call>{\"name\":\"read\",\"arguments\":{\"file\":\"a.txt\"}}</tool_call>\nTool calls must go through the tool-call protocol, never in the answer text. Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"arguments": "{\"file\":\"a.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "ligne 1\nligne 2",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
{
|
||||
"name": "arguments_illisibles",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Lis a.txt"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"file\\\":\",\"name\":\"read\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Pardon.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "[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.",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"tool_used": {
|
||||
"name": "read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"name": "read",
|
||||
"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.",
|
||||
"result_chars": 159
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Pardon."
|
||||
}
|
||||
],
|
||||
"extra": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "[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.",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
{
|
||||
"name": "budget_depasse",
|
||||
"cfg": {
|
||||
"AGENT_BUDGET": "2"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Explore"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"command\\\":\\\"ls 1\\\"}\",\"name\":\"bash\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"command\\\":\\\"ls 2\\\"}\",\"name\":\"bash\"},\"id\":\"c2\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"command\\\":\\\"ls 3\\\"}\",\"name\":\"bash\"},\"id\":\"c3\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"J'arrête là.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Explore",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Explore",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"command\":\"ls 1\"}",
|
||||
"name": "bash"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "a.txt\nb.txt",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Explore",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"command\":\"ls 1\"}",
|
||||
"name": "bash"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "a.txt\nb.txt",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"command\":\"ls 2\"}",
|
||||
"name": "bash"
|
||||
},
|
||||
"id": "c2",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "a.txt\nb.txt",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c2"
|
||||
},
|
||||
{
|
||||
"content": "[system] You have made 2 tool calls 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.",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Explore",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"command\":\"ls 1\"}",
|
||||
"name": "bash"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "a.txt\nb.txt",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"command\":\"ls 2\"}",
|
||||
"name": "bash"
|
||||
},
|
||||
"id": "c2",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "a.txt\nb.txt",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c2"
|
||||
},
|
||||
{
|
||||
"content": "[system] You have made 2 tool calls 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.",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"command\":\"ls 3\"}",
|
||||
"name": "bash"
|
||||
},
|
||||
"id": "c3",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "a.txt\nb.txt",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c3"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "ls 1",
|
||||
"name": "bash",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "ls 1",
|
||||
"name": "bash"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "ls 1",
|
||||
"name": "bash",
|
||||
"result": "a.txt\nb.txt",
|
||||
"result_chars": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "ls 2",
|
||||
"name": "bash",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "ls 2",
|
||||
"name": "bash"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "ls 2",
|
||||
"name": "bash",
|
||||
"result": "a.txt\nb.txt",
|
||||
"result_chars": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "ls 3",
|
||||
"name": "bash",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "ls 3",
|
||||
"name": "bash"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "ls 3",
|
||||
"name": "bash",
|
||||
"result": "a.txt\nb.txt",
|
||||
"result_chars": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "J'arrête là."
|
||||
}
|
||||
],
|
||||
"extra": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"arguments": "{\"command\":\"ls 1\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "a.txt\nb.txt",
|
||||
"tool_call_id": "c1"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"arguments": "{\"command\":\"ls 2\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "a.txt\nb.txt",
|
||||
"tool_call_id": "c2"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[system] You have made 2 tool calls 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."
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c3",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"arguments": "{\"command\":\"ls 3\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "a.txt\nb.txt",
|
||||
"tool_call_id": "c3"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
{
|
||||
"name": "deux_outils_en_parallele",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Liste puis lis"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\",\"name\":\"bash\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"file\\\":\\\"a.txt\\\"}\",\"name\":\"read\"},\"id\":\"c2\",\"index\":1,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Fait.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Liste puis lis",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Liste puis lis",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"command\":\"ls\"}",
|
||||
"name": "bash"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"a.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c2",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "a.txt\nb.txt",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
},
|
||||
{
|
||||
"content": "ligne 1\nligne 2",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c2"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "ls",
|
||||
"name": "bash",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "ls",
|
||||
"name": "bash"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "ls",
|
||||
"name": "bash",
|
||||
"result": "a.txt\nb.txt",
|
||||
"result_chars": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"result": "ligne 1\nligne 2",
|
||||
"result_chars": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Fait."
|
||||
}
|
||||
],
|
||||
"extra": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"arguments": "{\"command\":\"ls\"}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"arguments": "{\"file\":\"a.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "a.txt\nb.txt",
|
||||
"tool_call_id": "c1"
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "ligne 1\nligne 2",
|
||||
"tool_call_id": "c2"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"name": "echantillonnage_et_effort",
|
||||
"cfg": {
|
||||
"REASONING_EFFORT": "low",
|
||||
"TOP_K": "40",
|
||||
"TOP_P": "0.9"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Salut"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Bonjour.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"chat_template_kwargs": {
|
||||
"reasoning_effort": "low"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Salut",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"reasoning_effort": "low",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
],
|
||||
"top_k": 40,
|
||||
"top_p": 0.9
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"content": "Bonjour."
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"name": "erreur_500_avec_outils",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Bonjour"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 500,
|
||||
"lines": [
|
||||
"{\"error\":{\"code\":500,\"message\":\"Failed to parse tool call\",\"type\":\"server_error\"}}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Salut.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Bonjour",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Bonjour\n\n[system] Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered.",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tool_choice": "none",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"content": "Salut."
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"name": "erreur_500_deux_fois",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Bonjour"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 500,
|
||||
"lines": [
|
||||
"{\"error\":{\"code\":500,\"message\":\"Failed to parse tool call\",\"type\":\"server_error\"}}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 500,
|
||||
"lines": [
|
||||
"{\"error\":{\"code\":500,\"message\":\"Failed to parse tool call\",\"type\":\"server_error\"}}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Salut.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Bonjour",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Bonjour\n\n[system] Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered.",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tool_choice": "none",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.\n\nDo not call any more tools. Answer now, directly, in the user's language, using only the information already gathered.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Bonjour",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"content": "Salut."
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"name": "erreur_moteur",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Bonjour"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 400,
|
||||
"lines": [
|
||||
"{\"error\":{\"code\":400,\"message\":\"requête invalide\",\"type\":\"invalid_request_error\"}}"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Bonjour",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"err": "llama-server a renvoyé 400 : {\"error\":{\"code\":400,\"message\":\"requête invalide\",\"type\":\"invalid_request_error\"}}"
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": "llama-server a renvoyé 400 : {\"error\":{\"code\":400,\"message\":\"requête invalide\",\"type\":\"invalid_request_error\"}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
{
|
||||
"name": "historique_avec_outils",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Lis a.txt"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c0",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"arguments": "{\"file\":\"a.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "ligne 1",
|
||||
"tool_call_id": "c0"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Une ligne."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Et b.txt ?"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"file\\\":\\\"b.txt\\\"}\",\"name\":\"read\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Deux lignes.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"a.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c0",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "ligne 1",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c0"
|
||||
},
|
||||
{
|
||||
"content": "Une ligne.",
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"content": "Et b.txt ?",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"a.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c0",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "ligne 1",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c0"
|
||||
},
|
||||
{
|
||||
"content": "Une ligne.",
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"content": "Et b.txt ?",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"b.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "ligne 1\nligne 2",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "b.txt",
|
||||
"name": "read",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "b.txt",
|
||||
"name": "read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "b.txt",
|
||||
"name": "read",
|
||||
"result": "ligne 1\nligne 2",
|
||||
"result_chars": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Deux lignes."
|
||||
}
|
||||
],
|
||||
"extra": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"arguments": "{\"file\":\"b.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "ligne 1\nligne 2",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
{
|
||||
"name": "outil_inconnu",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Fais-le"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{}\",\"name\":\"voler\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Impossible.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Fais-le",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Fais-le",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{}",
|
||||
"name": "voler"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "[erreur] outil inconnu : voler",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"tool_used": {
|
||||
"name": "voler"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"name": "voler",
|
||||
"result": "[erreur] outil inconnu : voler",
|
||||
"result_chars": 30
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Impossible."
|
||||
}
|
||||
],
|
||||
"extra": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "voler",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "[erreur] outil inconnu : voler",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
{
|
||||
"name": "outil_puis_reponse",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Lis a.txt"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"file\\\":\\\"a.txt\\\"}\",\"name\":\"read\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Le fichier a deux lignes.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Lis a.txt",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"file\":\"a.txt\"}",
|
||||
"name": "read"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "ligne 1\nligne 2",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"typing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"label": "a.txt",
|
||||
"name": "read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "a.txt",
|
||||
"name": "read",
|
||||
"result": "ligne 1\nligne 2",
|
||||
"result_chars": 15
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Le fichier a deux lignes."
|
||||
}
|
||||
],
|
||||
"extra": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"arguments": "{\"file\":\"a.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "ligne 1\nligne 2",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
{
|
||||
"name": "pense_sans_agir",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Cherche"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Il faudrait lister les fichiers.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Voilà.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Cherche",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Cherche",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "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.",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"reasoning": "Il faudrait lister les fichiers."
|
||||
},
|
||||
{
|
||||
"drop_reasoning": true
|
||||
},
|
||||
{
|
||||
"content": "Voilà."
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"name": "pense_sans_agir_deux_fois",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Cherche"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Lister les fichiers.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Toujours lister.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Voilà.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Cherche",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Cherche",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "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.",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Cherche",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "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.",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "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.",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"reasoning": "Lister les fichiers."
|
||||
},
|
||||
{
|
||||
"drop_reasoning": true
|
||||
},
|
||||
{
|
||||
"reasoning": "Toujours lister."
|
||||
},
|
||||
{
|
||||
"drop_reasoning": true
|
||||
},
|
||||
{
|
||||
"content": "Voilà."
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"name": "pensee_en_ligne",
|
||||
"cfg": {
|
||||
"REASONING": "on"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Salut"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"\\u003cthink\\u003eIl dit bonjour.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"\\u003c/think\\u003eBonjour !\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Salut",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"content": "<think>Il dit b"
|
||||
},
|
||||
{
|
||||
"reasoning": "onjour."
|
||||
},
|
||||
{
|
||||
"content": "Bonjour !"
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"name": "raisonnement_coupe",
|
||||
"cfg": {
|
||||
"REASONING": "off"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Salut"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Bonjour.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": false
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Salut",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"content": "Bonjour."
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"name": "raisonnement_puis_reponse",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Combien font 2+2 ?"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Je calcule.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\" 4.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"4\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Combien font 2+2 ?",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"reasoning": "Je calcule. 4."
|
||||
},
|
||||
{
|
||||
"content": "4"
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"name": "reponse_simple",
|
||||
"cfg": {},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es Loki."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Bonjour"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read",
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Bon\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"jour !\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "ligne 1\nligne 2"
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Tu es Loki.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Bonjour",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
"name": "read",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path (relative to the working folder)",
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Lines (default 400, max 1000)",
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"description": "Start line (default 1)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"content": "Bonjour !"
|
||||
}
|
||||
],
|
||||
"extra": [],
|
||||
"err": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"cwd": "ws",
|
||||
"memory_dir": "memory/generale",
|
||||
"files": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"git_setup": [
|
||||
{
|
||||
"dir": "ws/depot",
|
||||
"args": [
|
||||
"init",
|
||||
"-q",
|
||||
"-b",
|
||||
"main"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": "ws/depot",
|
||||
"args": [
|
||||
"add",
|
||||
"a.txt",
|
||||
"b.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": "ws/depot",
|
||||
"args": [
|
||||
"-c",
|
||||
"user.name=Loki",
|
||||
"-c",
|
||||
"user.email=loki@test",
|
||||
"commit",
|
||||
"-q",
|
||||
"-m",
|
||||
"départ"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": "ws/vide",
|
||||
"args": [
|
||||
"init",
|
||||
"-q",
|
||||
"-b",
|
||||
"main"
|
||||
]
|
||||
}
|
||||
],
|
||||
"edits": [
|
||||
{
|
||||
"path": "ws/depot/a.txt",
|
||||
"content": "un\n2\ntrois\n",
|
||||
"age": 10
|
||||
},
|
||||
{
|
||||
"path": "ws/depot/neuf.txt",
|
||||
"content": "neuf\n",
|
||||
"age": 10
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "statut_ambigu",
|
||||
"tool": "git_status",
|
||||
"args": {},
|
||||
"label": "",
|
||||
"result": "[erreur] plusieurs dépôts ici (depot, vide) : précise dir"
|
||||
},
|
||||
{
|
||||
"name": "statut",
|
||||
"tool": "git_status",
|
||||
"args": {
|
||||
"dir": "depot"
|
||||
},
|
||||
"label": "",
|
||||
"result": "## main\n M a.txt\n?? neuf.txt"
|
||||
},
|
||||
{
|
||||
"name": "diff",
|
||||
"tool": "git_diff",
|
||||
"args": {
|
||||
"dir": "depot"
|
||||
},
|
||||
"label": "",
|
||||
"result": "diff --git a/a.txt b/a.txt\nindex 3d5dd13..283e2c0 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1,3 +1,3 @@\n un\n-deux\n+2\n trois"
|
||||
},
|
||||
{
|
||||
"name": "diff_fichier",
|
||||
"tool": "git_diff",
|
||||
"args": {
|
||||
"dir": "depot",
|
||||
"file": "b.txt"
|
||||
},
|
||||
"label": "b.txt",
|
||||
"result": "(aucune sortie)"
|
||||
},
|
||||
{
|
||||
"name": "diff_sans_commit",
|
||||
"tool": "git_diff",
|
||||
"args": {
|
||||
"dir": "vide"
|
||||
},
|
||||
"label": "",
|
||||
"result": "(aucune sortie)"
|
||||
},
|
||||
{
|
||||
"name": "statut_sans_commit",
|
||||
"tool": "git_status",
|
||||
"args": {
|
||||
"dir": "vide"
|
||||
},
|
||||
"label": "",
|
||||
"result": "## No commits yet on main\n?? x.txt"
|
||||
},
|
||||
{
|
||||
"name": "dossier_sortant",
|
||||
"tool": "git_status",
|
||||
"args": {
|
||||
"dir": "../x"
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] dossier invalide : ../x"
|
||||
},
|
||||
{
|
||||
"name": "dossier_absent",
|
||||
"tool": "git_status",
|
||||
"args": {
|
||||
"dir": "nulle-part"
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] chdir {{ROOT}}/ws/nulle-part: no such file or directory"
|
||||
},
|
||||
{
|
||||
"name": "clone_url_locale",
|
||||
"tool": "git_clone",
|
||||
"args": {
|
||||
"url": "file:///etc"
|
||||
},
|
||||
"label": "file:///etc",
|
||||
"result": "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés"
|
||||
},
|
||||
{
|
||||
"name": "clone_commande",
|
||||
"tool": "git_clone",
|
||||
"args": {
|
||||
"url": "ext::sh -c id"
|
||||
},
|
||||
"label": "ext::sh -c id",
|
||||
"result": "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés"
|
||||
},
|
||||
{
|
||||
"name": "clone_existant",
|
||||
"tool": "git_clone",
|
||||
"args": {
|
||||
"url": "https://exemple.invalid/u/depot.git"
|
||||
},
|
||||
"label": "https://exemple.invalid/u/depot.git",
|
||||
"result": "[erreur] le dossier depot existe déjà"
|
||||
},
|
||||
{
|
||||
"name": "clone_dossier_sortant",
|
||||
"tool": "git_clone",
|
||||
"args": {
|
||||
"dir": "../r",
|
||||
"url": "git@exemple.invalid:u/r"
|
||||
},
|
||||
"label": "git@exemple.invalid:u/r",
|
||||
"result": "[erreur] dossier cible invalide"
|
||||
}
|
||||
],
|
||||
"after": {
|
||||
"ws/depot/a.txt": "un\n2\ntrois\n",
|
||||
"ws/depot/b.txt": "bêta\n",
|
||||
"ws/depot/neuf.txt": "neuf\n",
|
||||
"ws/vide/x.txt": "x\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
{
|
||||
"cwd": "ws",
|
||||
"memory_dir": "memory/generale",
|
||||
"files": [
|
||||
{
|
||||
"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": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\nfin\n",
|
||||
"age": 20
|
||||
},
|
||||
{
|
||||
"path": "ws/crlf.txt",
|
||||
"content": "un\r\ndeux\r\ntrois\r\n",
|
||||
"age": 10
|
||||
},
|
||||
{
|
||||
"path": "ws/bin.dat",
|
||||
"content": "PK\u0000\u0001binaire",
|
||||
"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": "aiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\n",
|
||||
"age": 80
|
||||
},
|
||||
{
|
||||
"path": "dehors.txt",
|
||||
"content": "hors du dossier\n",
|
||||
"age": 90
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "lecture",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "a.txt"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "1: ligne 1\n2: ligne 2\n3: ligne 3"
|
||||
},
|
||||
{
|
||||
"name": "lecture_fenetre",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"limit": 1,
|
||||
"offset": 2
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "2: ligne 2\n… (1 lignes restantes — relis avec offset=3)"
|
||||
},
|
||||
{
|
||||
"name": "lecture_fenetre_decimale",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"limit": "3",
|
||||
"offset": 2.7
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "2: ligne 2\n3: ligne 3"
|
||||
},
|
||||
{
|
||||
"name": "lecture_chemin_vide",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": " "
|
||||
},
|
||||
"label": " ",
|
||||
"result": "[erreur] chemin vide"
|
||||
},
|
||||
{
|
||||
"name": "lecture_absent",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "absent.txt"
|
||||
},
|
||||
"label": "absent.txt",
|
||||
"result": "[erreur] open {{ROOT}}/ws/absent.txt: no such file or directory"
|
||||
},
|
||||
{
|
||||
"name": "lecture_dossier",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "src"
|
||||
},
|
||||
"label": "src",
|
||||
"result": "[erreur] read {{ROOT}}/ws/src: is a directory"
|
||||
},
|
||||
{
|
||||
"name": "lecture_binaire",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "bin.dat"
|
||||
},
|
||||
"label": "bin.dat",
|
||||
"result": "[binaire] bin.dat (11 octets) — pas un fichier texte"
|
||||
},
|
||||
{
|
||||
"name": "lecture_au_dela",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"offset": 99
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[erreur] offset 99 > 3 lignes"
|
||||
},
|
||||
{
|
||||
"name": "lecture_ligne_longue",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "long.txt"
|
||||
},
|
||||
"label": "long.txt",
|
||||
"result": "1: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…\n2: fin"
|
||||
},
|
||||
{
|
||||
"name": "lecture_fichier_vide",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "vide.txt"
|
||||
},
|
||||
"label": "vide.txt",
|
||||
"result": "[erreur] offset 1 > 0 lignes"
|
||||
},
|
||||
{
|
||||
"name": "lecture_absolue",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "{{ROOT}}/dehors.txt"
|
||||
},
|
||||
"label": "{{ROOT}}/dehors.txt",
|
||||
"result": "1: hors du dossier"
|
||||
},
|
||||
{
|
||||
"name": "lecture_relative_sortante",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "src/../../dehors.txt"
|
||||
},
|
||||
"label": "src/../../dehors.txt",
|
||||
"result": "1: hors du dossier"
|
||||
},
|
||||
{
|
||||
"name": "lecture_code_hors_dossier",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "../dehors.txt"
|
||||
},
|
||||
"code": true,
|
||||
"label": "../dehors.txt",
|
||||
"result": "[refusé] ../dehors.txt est hors du dossier de cette discussion. En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
},
|
||||
{
|
||||
"name": "lecture_code",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "b.txt"
|
||||
},
|
||||
"code": true,
|
||||
"label": "b.txt",
|
||||
"result": "1: alpha\n2: beta"
|
||||
},
|
||||
{
|
||||
"name": "grep",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"pattern": "ligne [12]"
|
||||
},
|
||||
"label": "ligne [12]",
|
||||
"result": "a.txt:1: ligne 1\na.txt:2: ligne 2"
|
||||
},
|
||||
{
|
||||
"name": "grep_filtre",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"glob": "*.go",
|
||||
"pattern": "x"
|
||||
},
|
||||
"label": "x",
|
||||
"result": "src/lib/util.go:2: // x marque\nsrc/main.go:3: func main() { x := 1 }"
|
||||
},
|
||||
{
|
||||
"name": "grep_filtre_classe",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"glob": "[mn]*",
|
||||
"pattern": "x"
|
||||
},
|
||||
"label": "x",
|
||||
"result": "src/lib/notes.md:1: x dans les notes\nsrc/main.go:3: func main() { x := 1 }"
|
||||
},
|
||||
{
|
||||
"name": "grep_fichier",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": "b.txt",
|
||||
"pattern": "beta"
|
||||
},
|
||||
"label": "beta",
|
||||
"result": ".:2: beta"
|
||||
},
|
||||
{
|
||||
"name": "grep_sous_dossier",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": "src/lib",
|
||||
"pattern": "package"
|
||||
},
|
||||
"label": "package",
|
||||
"result": "util.go:1: package lib"
|
||||
},
|
||||
{
|
||||
"name": "grep_point",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": ".",
|
||||
"pattern": "alpha"
|
||||
},
|
||||
"label": "alpha",
|
||||
"result": "b.txt:1: alpha"
|
||||
},
|
||||
{
|
||||
"name": "grep_aucun",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"pattern": "zzz"
|
||||
},
|
||||
"label": "zzz",
|
||||
"result": "[aucun résultat]"
|
||||
},
|
||||
{
|
||||
"name": "grep_motif_vide",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"pattern": " "
|
||||
},
|
||||
"label": " ",
|
||||
"result": "[erreur] motif vide"
|
||||
},
|
||||
{
|
||||
"name": "grep_plafond",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"pattern": "aiguille"
|
||||
},
|
||||
"label": "aiguille",
|
||||
"result": "beaucoup.txt:1: aiguille\nbeaucoup.txt:2: aiguille\nbeaucoup.txt:3: aiguille\nbeaucoup.txt:4: aiguille\nbeaucoup.txt:5: aiguille\nbeaucoup.txt:6: aiguille\nbeaucoup.txt:7: aiguille\nbeaucoup.txt:8: aiguille\nbeaucoup.txt:9: aiguille\nbeaucoup.txt:10: aiguille\nbeaucoup.txt:11: aiguille\nbeaucoup.txt:12: aiguille\nbeaucoup.txt:13: aiguille\nbeaucoup.txt:14: aiguille\nbeaucoup.txt:15: aiguille\nbeaucoup.txt:16: aiguille\nbeaucoup.txt:17: aiguille\nbeaucoup.txt:18: aiguille\nbeaucoup.txt:19: aiguille\nbeaucoup.txt:20: aiguille\nbeaucoup.txt:21: aiguille\nbeaucoup.txt:22: aiguille\nbeaucoup.txt:23: aiguille\nbeaucoup.txt:24: aiguille\nbeaucoup.txt:25: aiguille\nbeaucoup.txt:26: aiguille\nbeaucoup.txt:27: aiguille\nbeaucoup.txt:28: aiguille\nbeaucoup.txt:29: aiguille\nbeaucoup.txt:30: aiguille\nbeaucoup.txt:31: aiguille\nbeaucoup.txt:32: aiguille\nbeaucoup.txt:33: aiguille\nbeaucoup.txt:34: aiguille\nbeaucoup.txt:35: aiguille\nbeaucoup.txt:36: aiguille\nbeaucoup.txt:37: aiguille\nbeaucoup.txt:38: aiguille\nbeaucoup.txt:39: aiguille\nbeaucoup.txt:40: aiguille\nbeaucoup.txt:41: aiguille\nbeaucoup.txt:42: aiguille\nbeaucoup.txt:43: aiguille\nbeaucoup.txt:44: aiguille\nbeaucoup.txt:45: aiguille\nbeaucoup.txt:46: aiguille\nbeaucoup.txt:47: aiguille\nbeaucoup.txt:48: aiguille\nbeaucoup.txt:49: aiguille\nbeaucoup.txt:50: aiguille\nbeaucoup.txt:51: aiguille\nbeaucoup.txt:52: aiguille\nbeaucoup.txt:53: aiguille\nbeaucoup.txt:54: aiguille\nbeaucoup.txt:55: aiguille\nbeaucoup.txt:56: aiguille\nbeaucoup.txt:57: aiguille\nbeaucoup.txt:58: aiguille\nbeaucoup.txt:59: aiguille\nbeaucoup.txt:60: aiguille\n… (plafond de 60 résultats atteint — affine le motif)"
|
||||
},
|
||||
{
|
||||
"name": "grep_absent",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": "nulle-part",
|
||||
"pattern": "a"
|
||||
},
|
||||
"label": "a",
|
||||
"result": "[aucun résultat]"
|
||||
},
|
||||
{
|
||||
"name": "grep_code_hors_dossier",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": "{{ROOT}}",
|
||||
"pattern": "a"
|
||||
},
|
||||
"code": true,
|
||||
"label": "a",
|
||||
"result": "[refusé] {{ROOT}} est hors du dossier de cette discussion. En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
},
|
||||
{
|
||||
"name": "glob_go",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "**/*.go"
|
||||
},
|
||||
"label": "**/*.go",
|
||||
"result": "src/lib/util.go\nsrc/main.go"
|
||||
},
|
||||
{
|
||||
"name": "glob_racine",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "*.txt"
|
||||
},
|
||||
"label": "*.txt",
|
||||
"result": "crlf.txt\nlong.txt\nvide.txt\nb.txt\na.txt\nbeaucoup.txt"
|
||||
},
|
||||
{
|
||||
"name": "glob_point",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "./src/*"
|
||||
},
|
||||
"label": "./src/*",
|
||||
"result": "src/main.go"
|
||||
},
|
||||
{
|
||||
"name": "glob_aucun",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "*.rs"
|
||||
},
|
||||
"label": "*.rs",
|
||||
"result": "[aucun fichier]"
|
||||
},
|
||||
{
|
||||
"name": "glob_vide",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": ""
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] motif vide"
|
||||
},
|
||||
{
|
||||
"name": "question",
|
||||
"tool": "ask",
|
||||
"args": {
|
||||
"options": [
|
||||
"a",
|
||||
" ",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e"
|
||||
],
|
||||
"question": "Lequel ?"
|
||||
},
|
||||
"label": "Lequel ?",
|
||||
"result": "[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message.",
|
||||
"ask": {
|
||||
"question": "Lequel ?",
|
||||
"options": [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "question_vide",
|
||||
"tool": "ask",
|
||||
"args": {
|
||||
"question": ""
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] question vide"
|
||||
},
|
||||
{
|
||||
"name": "ecriture_neuve",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "un\ndeux\n",
|
||||
"file": "neuf/n.txt"
|
||||
},
|
||||
"label": "neuf/n.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/neuf/n.txt créé (8 octets)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "+",
|
||||
"text": "un"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "deux"
|
||||
}
|
||||
],
|
||||
"added": 2
|
||||
},
|
||||
{
|
||||
"name": "ecriture_existant",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "remplacé\n",
|
||||
"file": "b.txt"
|
||||
},
|
||||
"label": "b.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/b.txt réécrit (10 octets)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "+",
|
||||
"text": "remplacé"
|
||||
}
|
||||
],
|
||||
"added": 1
|
||||
},
|
||||
{
|
||||
"name": "ecriture_memoire",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "x\n",
|
||||
"file": "{{MEM}}/x.md"
|
||||
},
|
||||
"label": "{{ROOT}}/memory/generale/x.md",
|
||||
"result": "[refusé] pas d'accès direct au dossier {{ROOT}}/memory/generale. Utilise les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete).",
|
||||
"diff": [
|
||||
{
|
||||
"op": "+",
|
||||
"text": "x"
|
||||
}
|
||||
],
|
||||
"added": 1
|
||||
},
|
||||
{
|
||||
"name": "ecriture_chemin_vide",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "x",
|
||||
"file": ""
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] chemin vide"
|
||||
},
|
||||
{
|
||||
"name": "ecriture_code_non_lu",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "x\n",
|
||||
"file": "src/lib/notes.md"
|
||||
},
|
||||
"code": true,
|
||||
"label": "src/lib/notes.md",
|
||||
"result": "[refusé] {{ROOT}}/ws/src/lib/notes.md existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
|
||||
},
|
||||
{
|
||||
"name": "ecriture_code_hors_dossier",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "x\n",
|
||||
"file": "../evade.txt"
|
||||
},
|
||||
"code": true,
|
||||
"label": "../evade.txt",
|
||||
"result": "[refusé] ../evade.txt est hors du dossier de cette discussion. En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
},
|
||||
{
|
||||
"name": "ecriture_code_neuve",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "c\n",
|
||||
"file": "code.txt"
|
||||
},
|
||||
"code": true,
|
||||
"label": "code.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/code.txt créé (2 octets)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "+",
|
||||
"text": "c"
|
||||
}
|
||||
],
|
||||
"added": 1
|
||||
},
|
||||
{
|
||||
"name": "edition",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "ligne deux",
|
||||
"old": "ligne 2"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/a.txt modifié (1 remplacement)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "-",
|
||||
"text": "ligne 2"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "ligne deux"
|
||||
}
|
||||
],
|
||||
"added": 1,
|
||||
"removed": 1
|
||||
},
|
||||
{
|
||||
"name": "edition_deja_faite",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "ligne deux",
|
||||
"old": "ligne 2"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[ok] déjà à jour — le fichier contient déjà cette modification",
|
||||
"diff": [
|
||||
{
|
||||
"op": "-",
|
||||
"text": "ligne 2"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "ligne deux"
|
||||
}
|
||||
],
|
||||
"added": 1,
|
||||
"removed": 1
|
||||
},
|
||||
{
|
||||
"name": "edition_introuvable",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "y",
|
||||
"old": "zzz"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[erreur] old introuvable dans le fichier"
|
||||
},
|
||||
{
|
||||
"name": "edition_ambigue",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "l",
|
||||
"old": "ligne"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[erreur] old apparaît 3 fois — ajoute du contexte pour le rendre unique"
|
||||
},
|
||||
{
|
||||
"name": "edition_crlf",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "crlf.txt",
|
||||
"new": "2\n3",
|
||||
"old": "deux\ntrois"
|
||||
},
|
||||
"label": "crlf.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/crlf.txt modifié (1 remplacement)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "-",
|
||||
"text": "deux"
|
||||
},
|
||||
{
|
||||
"op": "-",
|
||||
"text": "trois"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "2"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "3"
|
||||
}
|
||||
],
|
||||
"added": 2,
|
||||
"removed": 2
|
||||
},
|
||||
{
|
||||
"name": "edition_old_vide",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "y",
|
||||
"old": ""
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[erreur] old vide"
|
||||
},
|
||||
{
|
||||
"name": "edition_fichier_absent",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "absent.txt",
|
||||
"new": "b",
|
||||
"old": "a"
|
||||
},
|
||||
"label": "absent.txt",
|
||||
"result": "[erreur] open {{ROOT}}/ws/absent.txt: no such file or directory"
|
||||
},
|
||||
{
|
||||
"name": "edition_code_non_lu",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "src/main.go",
|
||||
"new": "x := 2",
|
||||
"old": "x := 1"
|
||||
},
|
||||
"code": true,
|
||||
"label": "src/main.go",
|
||||
"result": "[refusé] {{ROOT}}/ws/src/main.go existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
|
||||
},
|
||||
{
|
||||
"name": "edition_code_apres_lecture",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "b.txt",
|
||||
"new": "rem",
|
||||
"old": "remplacé"
|
||||
},
|
||||
"code": true,
|
||||
"label": "b.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/b.txt modifié (1 remplacement)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "-",
|
||||
"text": "remplacé"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "rem"
|
||||
}
|
||||
],
|
||||
"added": 1,
|
||||
"removed": 1
|
||||
},
|
||||
{
|
||||
"name": "shell",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "echo bonjour"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "echo bonjour",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\nbonjour\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_sortie_et_erreurs",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "echo out; echo err >&2; exit 3"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "echo out; echo err >&2; exit 3",
|
||||
"result": "exit: 3 · {{DUR}}\n\nstdout:\nout\n\n\nstderr:\nerr\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_tail",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "printf 'a\\nb\\nc\\n' | tail -2"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "printf 'a\\nb\\nc\\n' | tail -2",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\nb\nc"
|
||||
},
|
||||
{
|
||||
"name": "shell_ansi",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "printf '\\033[31mrouge\\033[0m\\n'"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "printf '\\033[31mrouge\\033[0m\\n'",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\nrouge\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_dossier",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "ls src"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "ls src",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\nlib\nmain.go\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_longue_sortie",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "seq 1 3000"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "seq 1 3000",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\n[sortie tronquée : 13893 caractères au total, seuls les 8000 derniers sont gardés]\n1401\n1402\n1403\n1404\n1405\n1406\n1407\n1408\n1409\n1410\n1411\n1412\n1413\n1414\n1415\n1416\n1417\n1418\n1419\n1420\n1421\n1422\n1423\n1424\n1425\n1426\n1427\n1428\n1429\n1430\n1431\n1432\n1433\n1434\n1435\n1436\n1437\n1438\n1439\n1440\n1441\n1442\n1443\n1444\n1445\n1446\n1447\n1448\n1449\n1450\n1451\n1452\n1453\n1454\n1455\n1456\n1457\n1458\n1459\n1460\n1461\n1462\n1463\n1464\n1465\n1466\n1467\n1468\n1469\n1470\n1471\n1472\n1473\n1474\n1475\n1476\n1477\n1478\n1479\n1480\n1481\n1482\n1483\n1484\n1485\n1486\n1487\n1488\n1489\n1490\n1491\n1492\n1493\n1494\n1495\n1496\n1497\n1498\n1499\n1500\n1501\n1502\n1503\n1504\n1505\n1506\n1507\n1508\n1509\n1510\n1511\n1512\n1513\n1514\n1515\n1516\n1517\n1518\n1519\n1520\n1521\n1522\n1523\n1524\n1525\n1526\n1527\n1528\n1529\n1530\n1531\n1532\n1533\n1534\n1535\n1536\n1537\n1538\n1539\n1540\n1541\n1542\n1543\n1544\n1545\n1546\n1547\n1548\n1549\n1550\n1551\n1552\n1553\n1554\n1555\n1556\n1557\n1558\n1559\n1560\n1561\n1562\n1563\n1564\n1565\n1566\n1567\n1568\n1569\n1570\n1571\n1572\n1573\n1574\n1575\n1576\n1577\n1578\n1579\n1580\n1581\n1582\n1583\n1584\n1585\n1586\n1587\n1588\n1589\n1590\n1591\n1592\n1593\n1594\n1595\n1596\n1597\n1598\n1599\n1600\n1601\n1602\n1603\n1604\n1605\n1606\n1607\n1608\n1609\n1610\n1611\n1612\n1613\n1614\n1615\n1616\n1617\n1618\n1619\n1620\n1621\n1622\n1623\n1624\n1625\n1626\n1627\n1628\n1629\n1630\n1631\n1632\n1633\n1634\n1635\n1636\n1637\n1638\n1639\n1640\n1641\n1642\n1643\n1644\n1645\n1646\n1647\n1648\n1649\n1650\n1651\n1652\n1653\n1654\n1655\n1656\n1657\n1658\n1659\n1660\n1661\n1662\n1663\n1664\n1665\n1666\n1667\n1668\n1669\n1670\n1671\n1672\n1673\n1674\n1675\n1676\n1677\n1678\n1679\n1680\n1681\n1682\n1683\n1684\n1685\n1686\n1687\n1688\n1689\n1690\n1691\n1692\n1693\n1694\n1695\n1696\n1697\n1698\n1699\n1700\n1701\n1702\n1703\n1704\n1705\n1706\n1707\n1708\n1709\n1710\n1711\n1712\n1713\n1714\n1715\n1716\n1717\n1718\n1719\n1720\n1721\n1722\n1723\n1724\n1725\n1726\n1727\n1728\n1729\n1730\n1731\n1732\n1733\n1734\n1735\n1736\n1737\n1738\n1739\n1740\n1741\n1742\n1743\n1744\n1745\n1746\n1747\n1748\n1749\n1750\n1751\n1752\n1753\n1754\n1755\n1756\n1757\n1758\n1759\n1760\n1761\n1762\n1763\n1764\n1765\n1766\n1767\n1768\n1769\n1770\n1771\n1772\n1773\n1774\n1775\n1776\n1777\n1778\n1779\n1780\n1781\n1782\n1783\n1784\n1785\n1786\n1787\n1788\n1789\n1790\n1791\n1792\n1793\n1794\n1795\n1796\n1797\n1798\n1799\n1800\n1801\n1802\n1803\n1804\n1805\n1806\n1807\n1808\n1809\n1810\n1811\n1812\n1813\n1814\n1815\n1816\n1817\n1818\n1819\n1820\n1821\n1822\n1823\n1824\n1825\n1826\n1827\n1828\n1829\n1830\n1831\n1832\n1833\n1834\n1835\n1836\n1837\n1838\n1839\n1840\n1841\n1842\n1843\n1844\n1845\n1846\n1847\n1848\n1849\n1850\n1851\n1852\n1853\n1854\n1855\n1856\n1857\n1858\n1859\n1860\n1861\n1862\n1863\n1864\n1865\n1866\n1867\n1868\n1869\n1870\n1871\n1872\n1873\n1874\n1875\n1876\n1877\n1878\n1879\n1880\n1881\n1882\n1883\n1884\n1885\n1886\n1887\n1888\n1889\n1890\n1891\n1892\n1893\n1894\n1895\n1896\n1897\n1898\n1899\n1900\n1901\n1902\n1903\n1904\n1905\n1906\n1907\n1908\n1909\n1910\n1911\n1912\n1913\n1914\n1915\n1916\n1917\n1918\n1919\n1920\n1921\n1922\n1923\n1924\n1925\n1926\n1927\n1928\n1929\n1930\n1931\n1932\n1933\n1934\n1935\n1936\n1937\n1938\n1939\n1940\n1941\n1942\n1943\n1944\n1945\n1946\n1947\n1948\n1949\n1950\n1951\n1952\n1953\n1954\n1955\n1956\n1957\n1958\n1959\n1960\n1961\n1962\n1963\n1964\n1965\n1966\n1967\n1968\n1969\n1970\n1971\n1972\n1973\n1974\n1975\n1976\n1977\n1978\n1979\n1980\n1981\n1982\n1983\n1984\n1985\n1986\n1987\n1988\n1989\n1990\n1991\n1992\n1993\n1994\n1995\n1996\n1997\n1998\n1999\n2000\n2001\n2002\n2003\n2004\n2005\n2006\n2007\n2008\n2009\n2010\n2011\n2012\n2013\n2014\n2015\n2016\n2017\n2018\n2019\n2020\n2021\n2022\n2023\n2024\n2025\n2026\n2027\n2028\n2029\n2030\n2031\n2032\n2033\n2034\n2035\n2036\n2037\n2038\n2039\n2040\n2041\n2042\n2043\n2044\n2045\n2046\n2047\n2048\n2049\n2050\n2051\n2052\n2053\n2054\n2055\n2056\n2057\n2058\n2059\n2060\n2061\n2062\n2063\n2064\n2065\n2066\n2067\n2068\n2069\n2070\n2071\n2072\n2073\n2074\n2075\n2076\n2077\n2078\n2079\n2080\n2081\n2082\n2083\n2084\n2085\n2086\n2087\n2088\n2089\n2090\n2091\n2092\n2093\n2094\n2095\n2096\n2097\n2098\n2099\n2100\n2101\n2102\n2103\n2104\n2105\n2106\n2107\n2108\n2109\n2110\n2111\n2112\n2113\n2114\n2115\n2116\n2117\n2118\n2119\n2120\n2121\n2122\n2123\n2124\n2125\n2126\n2127\n2128\n2129\n2130\n2131\n2132\n2133\n2134\n2135\n2136\n2137\n2138\n2139\n2140\n2141\n2142\n2143\n2144\n2145\n2146\n2147\n2148\n2149\n2150\n2151\n2152\n2153\n2154\n2155\n2156\n2157\n2158\n2159\n2160\n2161\n2162\n2163\n2164\n2165\n2166\n2167\n2168\n2169\n2170\n2171\n2172\n2173\n2174\n2175\n2176\n2177\n2178\n2179\n2180\n2181\n2182\n2183\n2184\n2185\n2186\n2187\n2188\n2189\n2190\n2191\n2192\n2193\n2194\n2195\n2196\n2197\n2198\n2199\n2200\n2201\n2202\n2203\n2204\n2205\n2206\n2207\n2208\n2209\n2210\n2211Line truncated
|
||||
},
|
||||
{
|
||||
"name": "shell_delai",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "echo avant; sleep 3",
|
||||
"timeout": 1
|
||||
},
|
||||
"unix": true,
|
||||
"label": "echo avant; sleep 3",
|
||||
"result": "[timeout après 1s — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]\n\nstdout (partiel):\navant\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_dangereux",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "rm -rf /"
|
||||
},
|
||||
"label": "rm -rf /",
|
||||
"result": "[refusé] Commande bloquée par la politique de sécurité : efface la racine du disque ou le home. Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal."
|
||||
},
|
||||
{
|
||||
"name": "shell_memoire",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "cat {{MEM}}/x.md"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "cat {{ROOT}}/memory/generale/x.md",
|
||||
"result": "[refusé] pas d'accès direct au dossier {{ROOT}}/memory/generale via le shell. Utilise les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)."
|
||||
},
|
||||
{
|
||||
"name": "shell_code_arriere_plan",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "sleep 1 &"
|
||||
},
|
||||
"code": true,
|
||||
"unix": true,
|
||||
"label": "sleep 1 &",
|
||||
"result": "[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash."
|
||||
}
|
||||
],
|
||||
"after": {
|
||||
"dehors.txt": "hors du dossier\n",
|
||||
"ws/a.txt": "ligne 1\nligne deux\nligne 3\n",
|
||||
"ws/b.txt": "rem\n",
|
||||
"ws/beaucoup.txt": "aiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\n",
|
||||
"ws/bin.dat": "PK\u0000\u0001binaire",
|
||||
"ws/code.txt": "c\n",
|
||||
"ws/crlf.txt": "un\r\n2\r\n3\r\n",
|
||||
"ws/long.txt": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\nfin\n",
|
||||
"ws/neuf/n.txt": "un\ndeux\n",
|
||||
"ws/node_modules/dep/index.js": "ligne cachée\n",
|
||||
"ws/src/lib/notes.md": "x dans les notes\n",
|
||||
"ws/src/lib/util.go": "package lib\n// x marque\n",
|
||||
"ws/src/main.go": "package main\n\nfunc main() { x := 1 }\n",
|
||||
"ws/vide.txt": ""
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "erreur_moteur",
|
||||
"cfg": {
|
||||
"MEM_MODE": "off"
|
||||
},
|
||||
"send": {
|
||||
"cid": "c-erreur_moteur",
|
||||
"message": "Bonjour"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"status": 400,
|
||||
"lines": [
|
||||
"{\"error\":{\"code\":400,\"message\":\"requête invalide\",\"type\":\"invalid_request_error\"}}"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "1: ligne"
|
||||
},
|
||||
"start": {
|
||||
"caps": {
|
||||
"Agent": false,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
},
|
||||
"code_hint": false,
|
||||
"content": "Bonjour",
|
||||
"files": [],
|
||||
"model": "",
|
||||
"turn_caps": {
|
||||
"Agent": false,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
}
|
||||
},
|
||||
"view": {
|
||||
"head": [],
|
||||
"prefix": "",
|
||||
"tools": []
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Bonjour",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
{
|
||||
"user": "Bonjour"
|
||||
},
|
||||
{
|
||||
"error": "llama-server a renvoyé 400 : {\"error\":{\"code\":400,\"message\":\"requête invalide\",\"type\":\"invalid_request_error\"}}"
|
||||
},
|
||||
{
|
||||
"ctx_used": 5
|
||||
},
|
||||
{
|
||||
"turn_done": true
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Bonjour"
|
||||
}
|
||||
],
|
||||
"status": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
{
|
||||
"name": "outil_puis_reponse",
|
||||
"cfg": {
|
||||
"MEM_MODE": "off"
|
||||
},
|
||||
"agent": true,
|
||||
"send": {
|
||||
"cid": "c-outil_puis_reponse",
|
||||
"message": "Liste les fichiers"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\",\"name\":\"bash\"},\"id\":\"c1\",\"index\":0,\"type\":\"function\"}]}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Deux fichiers.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "1: ligne"
|
||||
},
|
||||
"start": {
|
||||
"caps": {
|
||||
"Agent": true,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
},
|
||||
"code_hint": false,
|
||||
"content": "Liste les fichiers",
|
||||
"files": [],
|
||||
"model": "",
|
||||
"turn_caps": {
|
||||
"Agent": true,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
}
|
||||
},
|
||||
"view": {
|
||||
"head": [],
|
||||
"prefix": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is bash: use its syntax.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: {{DATE}}\n\nMachine: host={{HOST}}, cwd={{TMP}}/workspace/discussions/{{CONV}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{TMP}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
|
||||
"name": "write",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Full content",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"content"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
|
||||
"name": "edit",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"new": {
|
||||
"description": "Replacement",
|
||||
"type": "string"
|
||||
},
|
||||
"old": {
|
||||
"description": "Exact text to replace (unique)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"old",
|
||||
"new"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
||||
"name": "recall",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Block id, e.g. \"r7\".",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
||||
"name": "recall_search",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"description": "Default 5, max 20",
|
||||
"type": "integer"
|
||||
},
|
||||
"query": {
|
||||
"description": "Keywords describing the block.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
|
||||
"name": "task_list",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Show only this task, with its full last report",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
|
||||
"name": "task_create",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"at": {
|
||||
"description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\"",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Start active (default true)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"in_minutes": {
|
||||
"description": "One-time task, N minutes from now",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"description": "AI task: self-contained instruction to run each time (omit if using script)",
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)",
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Change one or more fields of a scheduled task by id.",
|
||||
"name": "task_update",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "false pauses, true resumes",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Delete a scheduled task by id.",
|
||||
"name": "task_delete",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is bash: use its syntax.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: {{DATE}}\n\nMachine: host={{HOST}}, cwd={{TMP}}/workspace/discussions/{{CONV}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{TMP}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Liste les fichiers",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
|
||||
"name": "write",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Full content",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"content"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
|
||||
"name": "edit",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"new": {
|
||||
"description": "Replacement",
|
||||
"type": "string"
|
||||
},
|
||||
"old": {
|
||||
"description": "Exact text to replace (unique)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"old",
|
||||
"new"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
||||
"name": "recall",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Block id, e.g. \"r7\".",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
||||
"name": "recall_search",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"description": "Default 5, max 20",
|
||||
"type": "integer"
|
||||
},
|
||||
"query": {
|
||||
"description": "Keywords describing the block.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
|
||||
"name": "task_list",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Show only this task, with its full last report",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
|
||||
"name": "task_create",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"at": {
|
||||
"description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\"",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Start active (default true)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"in_minutes": {
|
||||
"description": "One-time task, N minutes from now",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"description": "AI task: self-contained instruction to run each time (omit if using script)",
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)",
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Change one or more fields of a scheduled task by id.",
|
||||
"name": "task_update",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "false pauses, true resumes",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Delete a scheduled task by id.",
|
||||
"name": "task_delete",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is bash: use its syntax.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: {{DATE}}\n\nMachine: host={{HOST}}, cwd={{TMP}}/workspace/discussions/{{CONV}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{TMP}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Liste les fichiers",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"command\":\"ls\"}",
|
||||
"name": "bash"
|
||||
},
|
||||
"id": "c1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "a.txt\nb.txt",
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
|
||||
"name": "write",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Full content",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"content"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
|
||||
"name": "edit",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"new": {
|
||||
"description": "Replacement",
|
||||
"type": "string"
|
||||
},
|
||||
"old": {
|
||||
"description": "Exact text to replace (unique)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"old",
|
||||
"new"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
||||
"name": "recall",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Block id, e.g. \"r7\".",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
||||
"name": "recall_search",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"description": "Default 5, max 20",
|
||||
"type": "integer"
|
||||
},
|
||||
"query": {
|
||||
"description": "Keywords describing the block.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
|
||||
"name": "task_list",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Show only this task, with its full last report",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
|
||||
"name": "task_create",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"at": {
|
||||
"description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\"",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Start active (default true)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"in_minutes": {
|
||||
"description": "One-time task, N minutes from now",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"description": "AI task: self-contained instruction to run each time (omit if using script)",
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)",
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Change one or more fields of a scheduled task by id.",
|
||||
"name": "task_update",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "false pauses, true resumes",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Delete a scheduled task by id.",
|
||||
"name": "task_delete",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
{
|
||||
"user": "Liste les fichiers"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"tool_used": {
|
||||
"done": true,
|
||||
"label": "ls",
|
||||
"name": "bash",
|
||||
"result": "a.txt\nb.txt",
|
||||
"result_chars": 11,
|
||||
"typing": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "Deux fichiers.",
|
||||
"seq0": 8,
|
||||
"toks": 1
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"turn_done": true
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Liste les fichiers"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"arguments": "{\"command\":\"ls\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "a.txt\nb.txt",
|
||||
"tool_call_id": "c1"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Deux fichiers."
|
||||
}
|
||||
],
|
||||
"status": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
{
|
||||
"name": "pense_sans_agir",
|
||||
"cfg": {
|
||||
"MEM_MODE": "off"
|
||||
},
|
||||
"agent": true,
|
||||
"send": {
|
||||
"cid": "c-pense_sans_agir",
|
||||
"message": "Cherche"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Il faudrait lister.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Voilà.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "1: ligne"
|
||||
},
|
||||
"start": {
|
||||
"caps": {
|
||||
"Agent": true,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
},
|
||||
"code_hint": false,
|
||||
"content": "Cherche",
|
||||
"files": [],
|
||||
"model": "",
|
||||
"turn_caps": {
|
||||
"Agent": true,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
}
|
||||
},
|
||||
"view": {
|
||||
"head": [],
|
||||
"prefix": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is bash: use its syntax.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: {{DATE}}\n\nMachine: host={{HOST}}, cwd={{TMP}}/workspace/discussions/{{CONV}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{TMP}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
|
||||
"name": "write",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Full content",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"content"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
|
||||
"name": "edit",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"new": {
|
||||
"description": "Replacement",
|
||||
"type": "string"
|
||||
},
|
||||
"old": {
|
||||
"description": "Exact text to replace (unique)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"old",
|
||||
"new"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
||||
"name": "recall",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Block id, e.g. \"r7\".",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
||||
"name": "recall_search",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"description": "Default 5, max 20",
|
||||
"type": "integer"
|
||||
},
|
||||
"query": {
|
||||
"description": "Keywords describing the block.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
|
||||
"name": "task_list",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Show only this task, with its full last report",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
|
||||
"name": "task_create",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"at": {
|
||||
"description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\"",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Start active (default true)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"in_minutes": {
|
||||
"description": "One-time task, N minutes from now",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"description": "AI task: self-contained instruction to run each time (omit if using script)",
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)",
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Change one or more fields of a scheduled task by id.",
|
||||
"name": "task_update",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "false pauses, true resumes",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Delete a scheduled task by id.",
|
||||
"name": "task_delete",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is bash: use its syntax.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: {{DATE}}\n\nMachine: host={{HOST}}, cwd={{TMP}}/workspace/discussions/{{CONV}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{TMP}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Cherche",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
|
||||
"name": "write",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Full content",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"content"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
|
||||
"name": "edit",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"new": {
|
||||
"description": "Replacement",
|
||||
"type": "string"
|
||||
},
|
||||
"old": {
|
||||
"description": "Exact text to replace (unique)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"old",
|
||||
"new"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
||||
"name": "recall",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Block id, e.g. \"r7\".",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
||||
"name": "recall_search",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"description": "Default 5, max 20",
|
||||
"type": "integer"
|
||||
},
|
||||
"query": {
|
||||
"description": "Keywords describing the block.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
|
||||
"name": "task_list",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Show only this task, with its full last report",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
|
||||
"name": "task_create",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"at": {
|
||||
"description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\"",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Start active (default true)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"in_minutes": {
|
||||
"description": "One-time task, N minutes from now",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"description": "AI task: self-contained instruction to run each time (omit if using script)",
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)",
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Change one or more fields of a scheduled task by id.",
|
||||
"name": "task_update",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "false pauses, true resumes",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Delete a scheduled task by id.",
|
||||
"name": "task_delete",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is bash: use its syntax.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: {{DATE}}\n\nMachine: host={{HOST}}, cwd={{TMP}}/workspace/discussions/{{CONV}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{TMP}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "Cherche",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "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.",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"parallel_tool_calls": false,
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
|
||||
"name": "bash",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The command",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Timeout s (default 30, max 300)",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
|
||||
"name": "write",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Full content",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"content"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
|
||||
"name": "edit",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"description": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"new": {
|
||||
"description": "Replacement",
|
||||
"type": "string"
|
||||
},
|
||||
"old": {
|
||||
"description": "Exact text to replace (unique)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"old",
|
||||
"new"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
||||
"name": "recall",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Block id, e.g. \"r7\".",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
||||
"name": "recall_search",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"description": "Default 5, max 20",
|
||||
"type": "integer"
|
||||
},
|
||||
"query": {
|
||||
"description": "Keywords describing the block.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
|
||||
"name": "task_list",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Show only this task, with its full last report",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
|
||||
"name": "task_create",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"at": {
|
||||
"description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\"",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Start active (default true)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"in_minutes": {
|
||||
"description": "One-time task, N minutes from now",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"description": "AI task: self-contained instruction to run each time (omit if using script)",
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)",
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Change one or more fields of a scheduled task by id.",
|
||||
"name": "task_update",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "false pauses, true resumes",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"description": "Delete a scheduled task by id.",
|
||||
"name": "task_delete",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
{
|
||||
"user": "Cherche"
|
||||
},
|
||||
{
|
||||
"reasoning_content": "Il faudrait lister.",
|
||||
"seq0": 2,
|
||||
"toks": 1
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"drop_reasoning": true
|
||||
},
|
||||
{
|
||||
"content": "Voilà.",
|
||||
"seq0": 7,
|
||||
"toks": 1
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"turn_done": true
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Cherche"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Voilà."
|
||||
}
|
||||
],
|
||||
"status": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"name": "raisonnement",
|
||||
"cfg": {
|
||||
"MEM_MODE": "off"
|
||||
},
|
||||
"send": {
|
||||
"cid": "c-raisonnement",
|
||||
"message": "Combien ?"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Je compte.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Quatre.\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "1: ligne"
|
||||
},
|
||||
"start": {
|
||||
"caps": {
|
||||
"Agent": false,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
},
|
||||
"code_hint": false,
|
||||
"content": "Combien ?",
|
||||
"files": [],
|
||||
"model": "",
|
||||
"turn_caps": {
|
||||
"Agent": false,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
}
|
||||
},
|
||||
"view": {
|
||||
"head": [],
|
||||
"prefix": "",
|
||||
"tools": []
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Combien ?",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
{
|
||||
"user": "Combien ?"
|
||||
},
|
||||
{
|
||||
"reasoning_content": "Je compte.",
|
||||
"seq0": 2,
|
||||
"toks": 1
|
||||
},
|
||||
{
|
||||
"content": "Quatre.",
|
||||
"seq0": 3,
|
||||
"toks": 1
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"turn_done": true
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Combien ?"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Quatre."
|
||||
}
|
||||
],
|
||||
"status": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "reponse_simple",
|
||||
"cfg": {
|
||||
"MEM_MODE": "off"
|
||||
},
|
||||
"send": {
|
||||
"cid": "c-reponse_simple",
|
||||
"message": "Bonjour"
|
||||
},
|
||||
"replies": [
|
||||
{
|
||||
"status": 200,
|
||||
"lines": [
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"Bon\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"jour !\"}}]}",
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":100}}",
|
||||
"data: [DONE]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tool_results": {
|
||||
"bash": "a.txt\nb.txt",
|
||||
"read": "1: ligne"
|
||||
},
|
||||
"start": {
|
||||
"caps": {
|
||||
"Agent": false,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
},
|
||||
"code_hint": false,
|
||||
"content": "Bonjour",
|
||||
"files": [],
|
||||
"model": "",
|
||||
"turn_caps": {
|
||||
"Agent": false,
|
||||
"Code": false,
|
||||
"ComputerUse": false,
|
||||
"Internet": false,
|
||||
"Mem": "off",
|
||||
"Role": ""
|
||||
}
|
||||
},
|
||||
"view": {
|
||||
"head": [],
|
||||
"prefix": "",
|
||||
"tools": []
|
||||
},
|
||||
"expect": {
|
||||
"requests": [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"content": "Bonjour",
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "loki",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.7
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
{
|
||||
"user": "Bonjour"
|
||||
},
|
||||
{
|
||||
"content": "Bonjour !",
|
||||
"seq0": 2,
|
||||
"toks": 2
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"stats": "…"
|
||||
},
|
||||
{
|
||||
"turn_done": true
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Bonjour"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Bonjour !"
|
||||
}
|
||||
],
|
||||
"status": 200
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
|
||||
home: std::env::temp_dir(),
|
||||
service: "loki-engine".into(),
|
||||
},
|
||||
chat: None,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Parité des outils fichiers et du shell avec le Go (lot 7).
|
||||
//!
|
||||
//! `tests/parity_tools/outils.json` : journal produit par `execTool`
|
||||
//! (`internal/loki/parity_tools_test.go`, régénéré par
|
||||
//! `LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils`). La
|
||||
//! même arborescence est recréée ici et les mêmes appels passent, dans le même
|
||||
//! ordre, par `tools::exec_local` (`common::toolsfx`) : libellés, résultats,
|
||||
//! diffs, questions et arborescence finale doivent être ceux du Go.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use common::toolsfx::{FakeState, replay};
|
||||
use loki_rs::chat::stream::tool_call_label;
|
||||
use loki_rs::tools::{ToolCtx, exec_local};
|
||||
|
||||
#[tokio::test]
|
||||
async fn outils_comme_le_go() {
|
||||
let diffs = replay("outils").await;
|
||||
assert!(
|
||||
diffs.is_empty(),
|
||||
"{} écart(s) avec le Go :\n\n{}",
|
||||
diffs.len(),
|
||||
diffs.join("\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// Outils non portés, ou cible distante : c'est au Go de les exécuter.
|
||||
#[tokio::test]
|
||||
async fn le_reste_va_au_go() {
|
||||
let st = FakeState {
|
||||
ctx: ToolCtx {
|
||||
cwd: PathBuf::from("/nulle-part"),
|
||||
memory_dir: PathBuf::from("/nulle-part/memory"),
|
||||
target: "poste".into(),
|
||||
code: false,
|
||||
},
|
||||
reads: Mutex::new(HashMap::new()),
|
||||
};
|
||||
let a = |v: Value| v.as_object().unwrap().clone();
|
||||
for (name, args) in [
|
||||
("mem_read", json!({"file":"x"})),
|
||||
("web_search", json!({"query":"x"})),
|
||||
("write", json!({"file":"a","content":"x"})),
|
||||
("edit", json!({"file":"a","old":"x","new":"y"})),
|
||||
("bash", json!({"command":"ls"})),
|
||||
] {
|
||||
let args = a(args);
|
||||
let label = tool_call_label(name, &args);
|
||||
assert!(
|
||||
exec_local(&st, false, name, &args, &label).await.is_none(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
// Une commande catastrophique est refusée ici, cible distante comprise.
|
||||
let args = a(json!({"command":"rm -rf /"}));
|
||||
let out = exec_local(&st, false, "bash", &args, "rm -rf /")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.result.starts_with("[refusé] Commande bloquée"));
|
||||
// read/grep/glob lisent toujours cette machine.
|
||||
let args = a(json!({"pattern":"*"}));
|
||||
assert!(exec_local(&st, false, "glob", &args, "*").await.is_some());
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Parité des outils git avec le Go (lot 7) : `tests/parity_tools/git.json`
|
||||
//! (`TestPariteOutilsGit`), rejoué dans `tools::exec_local`. Binaire à part :
|
||||
//! la configuration de git de la machine est écartée par des variables
|
||||
//! d'environnement, posées avant tout autre fil.
|
||||
|
||||
mod common;
|
||||
|
||||
#[tokio::test]
|
||||
async fn outils_git_comme_le_go() {
|
||||
if std::process::Command::new("git")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("git absent : test sauté");
|
||||
return;
|
||||
}
|
||||
// SAFETY : seul test de ce binaire, rien d'autre ne lit l'environnement.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"GIT_CONFIG_GLOBAL",
|
||||
if cfg!(windows) { "NUL" } else { "/dev/null" },
|
||||
);
|
||||
std::env::set_var("GIT_CONFIG_NOSYSTEM", "1");
|
||||
}
|
||||
let diffs = common::toolsfx::replay("git").await;
|
||||
assert!(
|
||||
diffs.is_empty(),
|
||||
"{} écart(s) avec le Go :\n\n{}",
|
||||
diffs.len(),
|
||||
diffs.join("\n\n")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user