mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac74ecde0b | ||
|
|
776de75dd0 | ||
|
|
6a45f19831 | ||
|
|
d22a087d8e | ||
|
|
0ef2ed72f5 | ||
|
|
615e58dd2f | ||
|
|
9792953d62 | ||
|
|
4c2cf8b4e6 | ||
|
|
68dd65cd5a | ||
|
|
01c11be4ae | ||
|
|
00e09a71e3 | ||
|
|
cbbe62ff97 | ||
|
|
e8563d992c | ||
|
|
ded1c97c17 | ||
|
|
92e9adee7d | ||
|
|
db1f20c0a4 | ||
|
|
fe51bdeee9 | ||
|
|
7288db632e | ||
|
|
f66d1b7621 | ||
|
|
bed6d07739 | ||
|
|
5197ee2542 | ||
|
|
90d6dd4a9a | ||
|
|
35886da9b1 | ||
|
|
76d9dce74a | ||
|
|
1d33ca5a49 | ||
|
|
e94b625931 | ||
|
|
869bd18bff | ||
|
|
9dfce204bf | ||
|
|
c157da2141 | ||
|
|
ad52da18a9 | ||
|
|
ff5c6d4571 | ||
|
|
f79b74dac3 | ||
|
|
a005626e66 | ||
|
|
89ed527c96 | ||
|
|
e444202b4b | ||
|
|
8a61879bfd | ||
|
|
f6c191922f | ||
|
|
84ae3a0cc4 | ||
|
|
8f61870224 | ||
|
|
9a0455be8f | ||
|
|
ed9b2e8c1c | ||
|
|
8c9bb6b450 | ||
|
|
0f3a3d52c1 | ||
|
|
78766a28d9 | ||
|
|
2c926a8274 | ||
|
|
25e31c1c26 | ||
|
|
090f15615e | ||
|
|
3fc36a3acc | ||
|
|
ec55f7d3d6 | ||
|
|
4c8ac93581 | ||
|
|
5edcad50a5 | ||
|
|
2db4cf1090 | ||
|
|
a4f8bd3ec4 | ||
|
|
2da2efc1fd | ||
|
|
a50b8f3cc6 | ||
|
|
13bcbc74fe | ||
|
|
1e5f0904f2 | ||
|
|
db9b0b44f5 | ||
|
|
eddcf97e3b | ||
|
|
cbd0d5e356 | ||
|
|
8bba80dee8 | ||
|
|
28f90543de | ||
|
|
46bfab3343 | ||
|
|
17ec2291d4 | ||
|
|
a33e5ccffe | ||
|
|
bbea5fcd3f | ||
|
|
98196e1730 | ||
|
|
57629f7254 | ||
|
|
911501ac0b | ||
|
|
dc262a44b8 | ||
|
|
f11f667a4c | ||
|
|
8fdef5455b | ||
|
|
daa2e7e4c1 | ||
|
|
a739534104 | ||
|
|
bc388d7a01 | ||
|
|
ad46bb69a6 | ||
|
|
306c7a2f6c | ||
|
|
846b561efb | ||
|
|
e96ffe319c | ||
|
|
b5649eb506 | ||
|
|
f2d2669690 | ||
|
|
ef11454a21 | ||
|
|
fea055fd52 | ||
|
|
e114ba3800 | ||
|
|
73bbf92f69 | ||
|
|
bec3d2cbfd | ||
|
|
a73b20ea04 | ||
|
|
74c2d5105c | ||
|
|
091902039d | ||
|
|
27ce303d7f | ||
|
|
f15baa79aa | ||
|
|
d8e2c3dbf6 | ||
|
|
4c615d131f | ||
|
|
e862ac834e | ||
|
|
7defedf141 | ||
|
|
0c1760c31a | ||
|
|
461ef8030d | ||
|
|
c38b1ac43e | ||
|
|
f62271b7fe | ||
|
|
b2fc40d48e | ||
|
|
c744b744ee | ||
|
|
99e456b9a2 | ||
|
|
73db4459d7 | ||
|
|
d805d9f1ba | ||
|
|
0d75e90386 | ||
|
|
b98ee5c3e7 | ||
|
|
dc6a001581 | ||
|
|
2fcafa4ffe | ||
|
|
a4b0885a22 | ||
|
|
c9c9ac1ddd | ||
|
|
451770bfcf | ||
|
|
7017b66c08 | ||
|
|
bea29bc6a9 | ||
|
|
a672760f3c | ||
|
|
d4fa4488cc | ||
|
|
a99d988e70 | ||
|
|
4b859c4eb6 | ||
|
|
0a6914c936 | ||
|
|
342f787eff | ||
|
|
f1f73cfc50 |
No files matched your search
@@ -8,3 +8,4 @@ dist/
|
||||
!README.md
|
||||
.env
|
||||
.env.*
|
||||
rust/target/
|
||||
@@ -31,6 +31,24 @@ jobs:
|
||||
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
|
||||
done
|
||||
|
||||
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: rust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rust
|
||||
- run: cargo fmt --all --check
|
||||
- run: cargo clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo test --locked
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
LOKI_VERSION=${{ github.sha }}
|
||||
LOKI_BUILD=${{ github.run_number }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
|
||||
@@ -61,3 +62,56 @@ jobs:
|
||||
# pas : seul le code Go et l'assemblage de l'image se refont.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Release « rolling » : les binaires que le conteneur installe lui-même au
|
||||
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
|
||||
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
|
||||
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
|
||||
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
|
||||
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
|
||||
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
|
||||
# voit pas.
|
||||
rolling:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: rolling-release
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-musl
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rust
|
||||
- name: Outils musl
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
|
||||
- name: Binaires
|
||||
run: |
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
|
||||
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
|
||||
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
|
||||
file dist/loki-rs-linux
|
||||
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
|
||||
abi=$(tr -d '[:space:]' < IMAGE_ABI)
|
||||
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
|
||||
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
|
||||
cat dist/manifest.json dist/SHA256SUMS
|
||||
- name: Publier
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release view rolling >/dev/null 2>&1 || \
|
||||
gh release create rolling --prerelease --target "$GITHUB_SHA" \
|
||||
--title "rolling (mise à jour au démarrage)" \
|
||||
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
|
||||
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
|
||||
gh release upload rolling dist/manifest.json --clobber
|
||||
@@ -22,3 +22,6 @@ Thumbs.db
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
node_modules/
|
||||
|
||||
# Rust (façade loki-rs)
|
||||
rust/target/
|
||||
+27
@@ -42,6 +42,23 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
|
||||
# limité à cette étape de build.
|
||||
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
||||
|
||||
# ── Étape 1 ter : façade Rust loki-rs ───────────────────────────────────
|
||||
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
|
||||
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
|
||||
# le même que celui publié dans la release « rolling » et installé à chaud
|
||||
# dans /data/bin par `loki-rs self-update`.
|
||||
FROM rust:1-alpine AS rustbuild
|
||||
RUN apk add --no-cache musl-dev gcc
|
||||
WORKDIR /src
|
||||
# L'UI est embarquée par build.rs depuis la même source que le go:embed : les
|
||||
# deux binaires d'une image servent toujours la même interface.
|
||||
COPY internal/loki/ui/ internal/loki/ui/
|
||||
# Catalogue MCP embarqué par loki-rs (lot 20), le même que le Go.
|
||||
COPY internal/loki/mcp_catalog.json internal/loki/mcp_catalog.json
|
||||
COPY rust/ rust/
|
||||
RUN cd rust && cargo build --release --locked \
|
||||
&& mkdir -p /out && cp target/release/loki-rs /out/loki-rs
|
||||
|
||||
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
|
||||
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
|
||||
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
|
||||
@@ -131,6 +148,7 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
||||
|
||||
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
||||
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
|
||||
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
|
||||
# l'image : téléchargé à la demande dans /data/asr/.
|
||||
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
|
||||
@@ -147,6 +165,15 @@ ENV LOKI_CONTAINER=1 \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
|
||||
# dépendances d'image les binaires exigent — un binaire publié pour une autre
|
||||
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
|
||||
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
|
||||
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
|
||||
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
|
||||
ARG LOKI_BUILD=0
|
||||
ENV LOKI_BUILD=${LOKI_BUILD}
|
||||
|
||||
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
|
||||
# conteneur — il n'est pas authentifié par défaut.
|
||||
EXPOSE 8090
|
||||
|
||||
@@ -52,15 +52,26 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
La **compréhension des vidéos** est reprise d'AJEAN (PR #100 de Trombo38 et
|
||||
son correctif « vidéo envoyée seulement si le moteur sait la lire ») :
|
||||
l'outil `see_video` et les vidéos jointes (`chat_video.go`,
|
||||
`chat_video_tool.go`), lues par llama.cpp avec `--video-fps` et ffmpeg ;
|
||||
ailleurs, la vidéo reste un fichier, avec la marche à suivre (ffmpeg puis
|
||||
`see_image`). Adaptée à Loki : vidéo de `see_video` toujours éphémère, vidéos
|
||||
jointes chiffrées comme le fil et retirées d'un fil rouvert, outil tenu aussi
|
||||
par la façade Rust (`imagetools.rs`).
|
||||
|
||||
## Moteur Strata
|
||||
|
||||
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
|
||||
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
|
||||
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
|
||||
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.1 »,
|
||||
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
|
||||
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
|
||||
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
|
||||
inclus dans l'image : ils sont téléchargés à la demande.
|
||||
release `moe-v1.1` (sources de Strata et binaire Linux CUDA 12 compilé, avec
|
||||
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
|
||||
par AJEAN. La reprise automatique d'une installation 1.0 (`strataMigrate`) est
|
||||
reprise d'AJEAN (`moeMigrate`). Ni Strata ni le modèle ne sont inclus dans
|
||||
l'image : ils sont téléchargés à la demande.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ seule image.
|
||||
|
||||
```
|
||||
┌────────────────── conteneur loki ──────────────────┐
|
||||
│ loki web (UI + API, port 8090, premier plan) │
|
||||
│ loki-rs serve (façade Rust, port 8090) │
|
||||
│ │ relaie HTTP/SSE/WebSocket │
|
||||
│ loki web (UI + API Go, 127.0.0.1:8091) │
|
||||
│ │ pilote (fichier PID — pas de systemd) │
|
||||
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
||||
│ ▲ modèles .gguf │
|
||||
@@ -35,6 +37,13 @@ seule image.
|
||||
redémarre le moteur normalement.
|
||||
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
|
||||
seule l'UI (8090) l'est.
|
||||
- **Migration vers Rust en cours**, par lots : la façade `loki-rs`
|
||||
(`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot
|
||||
lui fait reprendre des routes. Déjà servis par Rust : l'interface (HTML,
|
||||
JS, polices) et `/api/ping`, avec l'authentification de pilotage portée à
|
||||
l'identique (la clé est demandée au Go par une API interne locale,
|
||||
protégée par un jeton tiré à chaque démarrage). Plan et feuille de route :
|
||||
`docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
|
||||
|
||||
## Démarrage rapide (Docker, GPU NVIDIA)
|
||||
|
||||
@@ -102,7 +111,7 @@ Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
|
||||
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
|
||||
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
|
||||
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
|
||||
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par
|
||||
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.1 », vérifié par
|
||||
SHA-256).
|
||||
|
||||
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
|
||||
@@ -111,7 +120,26 @@ puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go d
|
||||
`/data/strata`). Le modèle installé devient un preset comme les autres :
|
||||
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
|
||||
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
|
||||
cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint).
|
||||
cache KV fp16/8 bits, prédiction MTP, lecture des images, seconde carte).
|
||||
|
||||
La seconde carte a trois réglages : **non utilisée**, **aide aux experts** (le
|
||||
défaut : elle garde et calcule une part des experts) ou **partage des couches**
|
||||
(les deux cartes se répartissent les couches du modèle). Le partage est souvent
|
||||
plus rapide avec deux cartes identiques (+50 % en génération mesuré par un
|
||||
utilisateur d'AJEAN sur deux RTX 3060) ; sur deux cartes très différentes,
|
||||
l'aide aux experts reste le meilleur choix. En partage des couches, le mode
|
||||
« hors RAM » n'est jamais choisi : la seconde carte n'y garde pas d'experts.
|
||||
|
||||
Depuis le paquet 1.1, la carte d'appoint calcule elle-même ses experts à la
|
||||
lecture des prompts (au lieu de les recopier vers la principale) : les ajouts
|
||||
de 100 à 200 jetons se lisent ~30 à 40 % plus vite (mesures d'AJEAN). Un modèle
|
||||
installé avec la 1.0 passe à la 1.1 **à son premier lancement**, sans
|
||||
réinstallation : Loki télécharge le nouveau paquet (~340 Mo), reprend
|
||||
l'environnement Python et la configuration de la 1.0 (le dossier
|
||||
`/data/strata/ajean-moe-1.0` doit donc rester en place) et réécrit le preset.
|
||||
En cas d'échec, la 1.0 continue de tourner et la raison est dans le journal du
|
||||
moteur. En mode « hors RAM », le contexte est plafonné à 131 072 jetons (au-delà,
|
||||
le cache KV verrouillé en RAM ne tient plus).
|
||||
|
||||
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
|
||||
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
|
||||
@@ -172,6 +200,32 @@ Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose
|
||||
`docker-compose.unraid.yml` → **Compose Up**.
|
||||
4. Interface : `http://<ip-unraid>:8090`.
|
||||
|
||||
## Mise à jour au démarrage
|
||||
|
||||
Un **simple redémarrage du conteneur** (ou du serveur Unraid) suffit à passer
|
||||
à la dernière version de Loki : pas besoin de `docker compose pull` pour les
|
||||
changements de code.
|
||||
|
||||
- À chaque push sur `main`, la CI publie la release GitHub
|
||||
[`rolling`](https://github.com/R0m1k3/Loki/releases/tag/rolling) : binaires
|
||||
`loki` (Go) et `loki-rs` (Rust), `SHA256SUMS`, `manifest.json`.
|
||||
- Au démarrage, `loki-rs self-update` les télécharge, vérifie leur empreinte
|
||||
SHA-256 et les installe dans `/data/bin/<build>/`. Le journal du conteneur
|
||||
dit ce qui s'est passé (`[loki-rs update] …`, puis
|
||||
`[entrypoint] binaires Loki : …`).
|
||||
- **Jamais bloquant** : réseau coupé, empreinte fausse, GitHub injoignable →
|
||||
les binaires déjà en place servent, le conteneur démarre normalement.
|
||||
- **Quand une nouvelle image est nécessaire** (nouvelle dépendance système,
|
||||
Python, Node, llama.cpp…), le fichier `IMAGE_ABI` est incrémenté : les
|
||||
conteneurs plus anciens n'installent pas ces binaires et le journal demande
|
||||
un `docker compose pull`.
|
||||
- La mise à jour est faite par le `loki-rs` **de l'image**, jamais par celui
|
||||
téléchargé : une version publiée défectueuse ne peut pas empêcher le
|
||||
correctif suivant d'arriver au redémarrage d'après.
|
||||
- `docker exec loki loki …` utilise aussi la version installée.
|
||||
- Couper : `LOKI_AUTO_UPDATE=0`. Revenir aux binaires de l'image : supprimer
|
||||
`/data/bin` et redémarrer avec `LOKI_AUTO_UPDATE=0`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
|
||||
@@ -189,6 +243,29 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
|
||||
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
|
||||
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
|
||||
| `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` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) les images gardées (`KEEP_TURN_IMAGES`), le préchauffage (`PREWARM`) et le bloc projet figé (`PROJ_SNAPSHOT`). `go` : la rend au Go (porte de sortie) | Rust |
|
||||
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent), et les outils mémoire de l'agent (mem_search, mem_read, mem_add, mem_edit, tracker) dans les tours de la façade, sont tenus par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust, qui compile aussi llama.cpp depuis les sources sous Linux et macOS ; les jobs du Go (compilation sous Windows, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
|
||||
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust (expérimental, migration en cours) ; le navigateur piloté, lui, est celui de la façade dès qu'elle tient la conversation | Go |
|
||||
| `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | 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
|
||||
@@ -1062,7 +1139,7 @@ Une étape à la fois, mesurée avant la suivante :
|
||||
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
|
||||
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
|
||||
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | au redémarrage du conteneur (release `rolling`) ; `docker compose pull` si l'image change |
|
||||
|
||||
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
|
||||
Pour récupérer les évolutions de l'amont :
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
|
||||
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
|
||||
#
|
||||
# Mise à jour : Compose Down puis Up avec « pull image », ou
|
||||
# « docker compose pull » avant Up.
|
||||
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
|
||||
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
|
||||
# Si le journal demande une nouvelle image : Compose Down puis Up avec
|
||||
# « pull image », ou « docker compose pull » avant Up.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
services:
|
||||
loki:
|
||||
@@ -39,6 +41,9 @@ services:
|
||||
# sinon l'API répond 403 (protection contre les sites tiers / DNS
|
||||
# rebinding). Inutile pour un accès par IP ou nom local.
|
||||
- LOKI_TRUSTED_HOSTS=
|
||||
# Conversation tenue par la façade Rust (défaut). « go » la rend au Go si
|
||||
# quelque chose ne va pas — puis Compose Down / Up.
|
||||
# - LOKI_CHAT=go
|
||||
volumes:
|
||||
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
|
||||
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
|
||||
|
||||
+39
-3
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
|
||||
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
|
||||
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
|
||||
# configuration au premier démarrage, lance le moteur (supervision PID, sans
|
||||
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
|
||||
set -eu
|
||||
|
||||
: "${LOKI_HOME:=/data}"
|
||||
@@ -15,6 +16,33 @@ if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
|
||||
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
|
||||
fi
|
||||
|
||||
# ── Mise à jour au démarrage ───────────────────────────────────────────────
|
||||
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
|
||||
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
|
||||
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
|
||||
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
|
||||
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
|
||||
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
|
||||
/usr/local/bin/loki-rs self-update || true
|
||||
|
||||
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
|
||||
# est plus récent que l'image et de même ABI, sinon l'image.
|
||||
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
|
||||
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
|
||||
BIN_DIR=/usr/local/bin
|
||||
fi
|
||||
echo "[entrypoint] binaires Loki : $BIN_DIR"
|
||||
# Tous les appels « loki » ci-dessous visent le binaire choisi.
|
||||
loki() { "$BIN_DIR/loki" "$@"; }
|
||||
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
|
||||
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
|
||||
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
|
||||
if [ "$BIN_DIR" != /usr/local/bin ]; then
|
||||
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
|
||||
else
|
||||
rm -f /usr/local/sbin/loki 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
|
||||
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
|
||||
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
|
||||
@@ -59,4 +87,12 @@ else
|
||||
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
|
||||
fi
|
||||
|
||||
exec loki web "${LOKI_WEB_PORT:-8090}"
|
||||
# Façade Rust (loki-rs) sur le port public ; elle lance `loki web` sur
|
||||
# 127.0.0.1:${LOKI_GO_PORT:-8091} et lui relaie tout. LOKI_FACADE=0 revient à
|
||||
# l'ancien schéma (Go seul, directement sur le port public) — porte de sortie
|
||||
# si la façade pose problème.
|
||||
export LOKI_WEB_PORT="${LOKI_WEB_PORT:-8090}"
|
||||
if [ "${LOKI_FACADE:-1}" = "0" ]; then
|
||||
exec "$BIN_DIR/loki" web "$LOKI_WEB_PORT"
|
||||
fi
|
||||
exec "$BIN_DIR/loki-rs" serve
|
||||
@@ -0,0 +1,122 @@
|
||||
# Migration Rust lot 1 — façade + mise à jour au démarrage — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Mettre un binaire Rust `loki-rs` en frontal du conteneur (proxy vers le Go) et faire que le conteneur prenne les derniers binaires publiés à chaque démarrage.
|
||||
|
||||
**Architecture:** `loki-rs serve` écoute sur `LOKI_WEB_PORT` (8090), lance `loki web 8091` (Go, `LOKI_WEB_BIND=127.0.0.1`) en enfant supervisé et lui proxifie tout (HTTP, SSE, WebSocket). `loki-rs self-update` télécharge la release GitHub `rolling` dans `/data/bin/<build>/`, `loki-rs which-bin` dit à l'entrypoint quel dossier de binaires utiliser.
|
||||
|
||||
**Tech Stack:** Rust 2021 (tokio, hyper 1, hyper-util, http-body-util, reqwest rustls, sha2, serde_json), cible `x86_64-unknown-linux-musl` ; Go 1.25 existant ; GitHub Actions.
|
||||
|
||||
Spec : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Port public inchangé : 8090 (`LOKI_WEB_PORT`). Go interne : `127.0.0.1:8091` (`LOKI_GO_PORT`).
|
||||
- Release : tag `rolling`, pré-release, assets `loki-linux`, `loki-rs-linux`, `SHA256SUMS`, `manifest.json` (uploadé en dernier).
|
||||
- `manifest.json` = `{"build": <github.run_number>, "abi": <IMAGE_ABI>, "commit": "<sha>"}`.
|
||||
- Source unique de l'ABI : fichier `IMAGE_ABI` à la racine (contenu `1`), copié dans l'image en `/etc/loki/IMAGE_ABI`. Build de l'image : `ENV LOKI_BUILD=<run_number>` (0 en build local).
|
||||
- Les binaires de `/data/bin/<build>/` ne sont utilisés que si `abi == IMAGE_ABI` **et** `build > LOKI_BUILD`.
|
||||
- `LOKI_AUTO_UPDATE=0` : aucune requête réseau. Le boot n'est jamais bloqué (code 0, timeouts bornés).
|
||||
- La façade efface `X-Loki-Via` entrant ; transmet `Host`, `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` intacts ; retire les hop-by-hop ; ne bufferise rien.
|
||||
- Go : `gofmt -l .` vide + `staticcheck ./...` propre. Rust : `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
|
||||
- Git : jamais `git add -A` ni `git stash` ; commits en français, style narratif.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
IMAGE_ABI ABI d'image (entier)
|
||||
rust/Cargo.toml workspace
|
||||
rust/loki-rs/Cargo.toml
|
||||
rust/loki-rs/src/main.rs CLI : serve | self-update | which-bin | version
|
||||
rust/loki-rs/src/proxy.rs proxy HTTP/SSE/WS vers le Go
|
||||
rust/loki-rs/src/child.rs supervision de `loki web`
|
||||
rust/loki-rs/src/update/mod.rs orchestration self-update + which-bin
|
||||
rust/loki-rs/src/update/sums.rs parse SHA256SUMS
|
||||
rust/loki-rs/src/update/state.rs release.json, décision, sélection du dossier
|
||||
rust/loki-rs/tests/proxy.rs intégration façade
|
||||
rust/loki-rs/tests/update.rs intégration self-update (faux serveur)
|
||||
internal/loki/web_server.go LOKI_WEB_BIND
|
||||
Dockerfile étape rustbuild, IMAGE_ABI, LOKI_BUILD
|
||||
docker-entrypoint.sh self-update, which-bin, exec loki-rs serve
|
||||
.github/workflows/ci.yml job rust
|
||||
.github/workflows/docker-publish.yml LOKI_BUILD + job rolling
|
||||
README.md section mise à jour au démarrage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Go — adresse d'écoute configurable
|
||||
|
||||
**Files:** Modify `internal/loki/web_server.go` (cmdWeb), Test `internal/loki/web_bind_test.go`
|
||||
|
||||
**Interfaces:** Produces `webBindAddr(port int) string` — `LOKI_WEB_BIND` (défaut `0.0.0.0`) + `:port`.
|
||||
|
||||
- [ ] Test : `t.Setenv("LOKI_WEB_BIND","")` → `"0.0.0.0:8090"` ; `"127.0.0.1"` → `"127.0.0.1:8091"`.
|
||||
- [ ] `go test ./internal/loki -run TestWebBindAddr` → FAIL (indéfini).
|
||||
- [ ] Implémenter, l'utiliser dans `cmdWeb` à la place de `fmt.Sprintf("0.0.0.0:%d", port)`.
|
||||
- [ ] Test PASS, `gofmt -l .` vide, staticcheck propre. Commit.
|
||||
|
||||
### Task 2: Workspace Rust + parse SHA256SUMS + état d'installation
|
||||
|
||||
**Files:** Create `rust/Cargo.toml`, `rust/loki-rs/Cargo.toml`, `src/main.rs` (squelette CLI), `src/update/{mod,sums,state}.rs`, `.gitignore` (`rust/target/`).
|
||||
|
||||
**Interfaces (produits) :**
|
||||
- `sums::parse(text:&str) -> HashMap<String,String>` (nom → hex minuscule ; accepte `*nom`, ignore lignes vides/invalides).
|
||||
- `state::Manifest { build:u64, abi:u32, commit:String }` (serde).
|
||||
- `state::Installed` = Manifest écrit dans `<dir>/release.json`.
|
||||
- `state::read_installed(dir:&Path) -> Option<Manifest>`.
|
||||
- `state::select_dir(data_bin:&Path, image_dir:&Path, image_build:u64, image_abi:u32) -> PathBuf` : `<data_bin>/<build>` si release.json valide, abi égal, build > image_build, et `loki` + `loki-rs` présents dedans ; sinon `image_dir`.
|
||||
- `state::needs_update(m:&Manifest, image_build:u64, image_abi:u32, installed:Option<&Manifest>) -> Decision` avec `enum Decision { Install, UpToDate, AbiMismatch }`.
|
||||
|
||||
- [ ] Tests unitaires : parse (2 espaces, `*`, majuscules → minuscules, ligne cassée ignorée) ; `needs_update` (abi ≠ → AbiMismatch ; build ≤ image → UpToDate ; build ≤ installé → UpToDate ; sinon Install) ; `select_dir` (aucun fichier → image ; abi ≠ → image ; build ≤ image → image ; binaire manquant → image ; ok → data dir).
|
||||
- [ ] `cargo test` FAIL puis implémentation, PASS, fmt + clippy. Commit.
|
||||
|
||||
### Task 3: self-update (téléchargement vérifié, installation atomique)
|
||||
|
||||
**Files:** `src/update/mod.rs`, `tests/update.rs`
|
||||
|
||||
**Interfaces :** `update::run(cfg:&UpdateConfig) -> Outcome` (jamais d'erreur remontée au CLI : journalise et rend `Outcome::{Installed(u64), UpToDate, AbiMismatch, Failed(String), Disabled}`) ; `UpdateConfig { base_url, data_bin, image_build, image_abi, enabled, timeout }` construit par `UpdateConfig::from_env()` (`LOKI_UPDATE_URL` défaut `https://github.com/R0m1k3/Loki/releases/download/rolling`, `LOKI_HOME/bin`, `LOKI_BUILD`, `/etc/loki/IMAGE_ABI` puis `LOKI_IMAGE_ABI`, `LOKI_AUTO_UPDATE`).
|
||||
|
||||
Algorithme : manifeste → décision → `SHA256SUMS` → télécharge `loki-linux`, `loki-rs-linux` dans `<data_bin>/.tmp-<build>/` → vérifie chaque hash → chmod 755 (unix) → renomme en `loki`, `loki-rs` → écrit `release.json` → renomme le dossier en `<data_bin>/<build>` → écrit `<data_bin>/release.json` (tmp + rename) → supprime les autres `<n>/` et `.tmp-*`. Toute erreur : supprime `.tmp-<build>`, `Failed`.
|
||||
|
||||
- [ ] Tests d'intégration (faux serveur hyper sur 127.0.0.1:0 servant des fichiers en mémoire) : installation OK (fichiers présents, release.json, select_dir pointe dessus) ; hash faux → rien installé, ancien état intact ; abi ≠ → rien téléchargé hormis le manifeste ; serveur absent → Failed, pas de panic ; `enabled=false` → Disabled sans requête ; seconde exécution → UpToDate et nettoyage des anciens builds.
|
||||
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
|
||||
|
||||
### Task 4: Façade proxy
|
||||
|
||||
**Files:** `src/proxy.rs`, `tests/proxy.rs`
|
||||
|
||||
**Interfaces :** `proxy::serve(listener: tokio::net::TcpListener, backend: SocketAddr, shutdown: impl Future) -> io::Result<()>`.
|
||||
|
||||
Comportement : hyper http1 + `with_upgrades` ; chaque requête → client hyper-util vers `backend` (URI `http://backend{path_and_query}`), Host d'origine conservé ; retire hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`, + noms listés dans `Connection`) et `x-loki-via` ; si requête d'upgrade (`Connection: upgrade` + `Upgrade`), garde `Connection: upgrade` + `Upgrade`, attend 101, puis `copy_bidirectional` entre les deux côtés upgradés ; corps en streaming dans les deux sens ; erreur de connexion → 502 `text/plain` « Loki (Go) redémarre — réessaie dans un instant ».
|
||||
|
||||
- [ ] Tests (faux backend hyper) : GET relayé avec Host/Authorization/Origin/X-Forwarded-Proto intacts et `X-Loki-Via` absent côté backend ; POST corps relayé ; SSE : backend envoie 1er événement puis attend 500 ms — le client reçoit le 1er avant 300 ms ; WebSocket écho via tokio-tungstenite ; backend arrêté → 502.
|
||||
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
|
||||
|
||||
### Task 5: Supervision du Go + commande `serve`
|
||||
|
||||
**Files:** `src/child.rs`, `src/main.rs`
|
||||
|
||||
**Interfaces :** `child::Supervisor::spawn(go_bin:PathBuf, port:u16) -> Supervisor` (relance avec backoff 1 s → 30 s, remis à 1 s après 60 s de vie ; stdin null, stdout/stderr hérités ; env `LOKI_WEB_BIND=127.0.0.1`) ; `Supervisor::shutdown(self, grace:Duration)` (SIGTERM unix / kill windows, puis kill après `grace`).
|
||||
`main serve` : go_bin = `LOKI_GO_BIN` sinon `loki` à côté de l'exe courant ; attend jusqu'à 30 s que `127.0.0.1:LOKI_GO_PORT` accepte (sinon ouvre quand même, 502 en attendant) ; `proxy::serve` sur `0.0.0.0:LOKI_WEB_PORT` ; SIGTERM/SIGINT → arrêt du proxy + `shutdown(10 s)`.
|
||||
`main which-bin` imprime `select_dir(...)` ; `main version` imprime version crate + `LOKI_BUILD`.
|
||||
|
||||
- [ ] Test unitaire `go_bin` par défaut ; test du backoff (fonction pure `next_backoff(prev, lived) -> Duration`).
|
||||
- [ ] Test manuel local : `loki-rs serve` avec un `loki.exe` construit → `curl localhost:8090/` renvoie l'UI.
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 6: Image, entrypoint, CI, release rolling, doc
|
||||
|
||||
**Files:** `IMAGE_ABI`, `Dockerfile`, `docker-entrypoint.sh`, `.github/workflows/ci.yml`, `.github/workflows/docker-publish.yml`, `README.md`.
|
||||
|
||||
- Dockerfile : étape `FROM rust:1-alpine AS rustbuild` (`apk add musl-dev`, `cargo build --release --locked`), copie `/usr/local/bin/loki-rs` ; `ARG LOKI_BUILD=0` → `ENV LOKI_BUILD` ; `COPY IMAGE_ABI /etc/loki/IMAGE_ABI`.
|
||||
- Entrypoint : `loki-rs self-update` (best-effort) ; `BIN_DIR=$(loki-rs which-bin)` ; `LOKI="$BIN_DIR/loki"` pour tous les appels config/start ; `exec "$BIN_DIR/loki-rs" serve`.
|
||||
- CI : job `rust` (fmt, clippy, test sur ubuntu).
|
||||
- docker-publish : `LOKI_BUILD=${{ github.run_number }}` en build-arg ; job `rolling` (contents: write, concurrency `rolling`) : build Go linux CGO=0, build Rust musl (`rustup target add x86_64-unknown-linux-musl`, `musl-tools`), SHA256SUMS, manifest, `gh release create rolling --prerelease` si absent, upload `--clobber` binaires+sums puis manifest.
|
||||
- README : section « Mise à jour au démarrage » (comportement, `LOKI_AUTO_UPDATE=0`, ABI → pull).
|
||||
- [ ] `sh -n docker-entrypoint.sh`, YAML relu, commit.
|
||||
|
||||
## Self-review
|
||||
|
||||
Spec couvert : façade (T4/T5), X-Loki-Via (T4), bind Go (T1), rolling + manifeste + ABI (T2/T3/T6), repli et boot non bloquant (T3/T6), désactivation (T3), tests (T2–T4), CI (T6). Écart assumé vs spec : ordre des versions par `build` (run_number) et non par commit, et installation dans `/data/bin/<build>/` (bascule atomique par `release.json`).
|
||||
@@ -0,0 +1,150 @@
|
||||
# Migration de Loki vers Rust — lot 1 : façade Rust + mise à jour au démarrage
|
||||
|
||||
Date : 2026-10-13
|
||||
Statut : validé (design), à planifier
|
||||
|
||||
## Contexte
|
||||
|
||||
Loki est aujourd'hui un monobinaire Go (`internal/loki`, ~65 000 lignes hors
|
||||
tests) qui sert une UI embarquée (~26 000 lignes JS/CSS/HTML) et pilote
|
||||
llama.cpp, Strata, MCP, le mode Code, etc. L'objectif à terme est de le
|
||||
réécrire en Rust. Une réécriture d'un bloc ne livrerait rien pendant des
|
||||
semaines : la migration est **progressive**, par lots, avec Rust en production
|
||||
dès le premier lot.
|
||||
|
||||
Second besoin : aujourd'hui, un `docker restart` relance la même image. Le
|
||||
conteneur doit prendre la dernière version de Loki **au prochain démarrage**,
|
||||
sans `docker compose pull` manuel.
|
||||
|
||||
## Architecture de cohabitation (« strangler fig »)
|
||||
|
||||
```
|
||||
:8090 ──► loki-rs (Rust, axum/tokio)
|
||||
├─ routes portées → traitées en Rust
|
||||
└─ tout le reste → proxy HTTP / SSE / WebSocket
|
||||
→ loki web (Go) sur 127.0.0.1:8091
|
||||
```
|
||||
|
||||
- Nouveau workspace Cargo dans `rust/`, binaire `loki-rs`, compilé en statique
|
||||
(`x86_64-unknown-linux-musl`) : indépendant de la glibc de l'image.
|
||||
- Go écoute uniquement en local sur 8091 (nouvelle option d'adresse d'écoute
|
||||
de `loki web`). Le port public reste 8090 : compose et reverse proxy
|
||||
inchangés.
|
||||
- Au lot 1, **aucune route n'est portée** : la façade proxifie tout. Elle sert
|
||||
de socle aux lots suivants.
|
||||
- Authentification (clé de pilotage, `LOKI_TRUSTED_HOSTS`) : reste à Go tant
|
||||
qu'il sert la route. Go ne fonde aucune décision d'accès sur l'IP source
|
||||
(vérifié : ni `RemoteAddr` ni `X-Forwarded-For` dans `internal/loki`), donc
|
||||
passer par 127.0.0.1 n'élargit rien. La façade transmet `Host`,
|
||||
`Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`
|
||||
**sans les altérer**, et retire seulement les en-têtes hop-by-hop.
|
||||
- **Sécurité** : la façade **efface `X-Loki-Via`** de toute requête entrante.
|
||||
Cet en-tête, posé par le tunnel du relais, dispense du contrôle d'hôte
|
||||
anti-DNS-rebinding (`crossSiteReject`). Aujourd'hui l'écoute HTTP directe de
|
||||
Go ne l'efface pas : sans clé de pilotage, une page en DNS rebinding peut le
|
||||
forger. La façade ferme ce trou dans le conteneur ; un correctif Go séparé
|
||||
le ferme pour `loki web` hors conteneur. Test dédié.
|
||||
- Flux : pas de timeout d'écriture, pas de bufferisation des réponses
|
||||
`text/event-stream` (chat SSE, `/v1` en streaming), upgrade WebSocket relayé
|
||||
(`/api/node/ws` et autres). Pas de recompression : Go gère déjà gzip.
|
||||
- Le relais `loki link` reste entièrement côté Go jusqu'à son propre lot.
|
||||
|
||||
### Démarrage et arrêt
|
||||
|
||||
`loki-rs serve` lance `loki web 8091` (Go) comme processus enfant, attend
|
||||
qu'il réponde, puis ouvre 8090. Si l'enfant meurt, la façade le relance
|
||||
(backoff borné) et répond 502 avec un message clair entre-temps. Sur SIGTERM,
|
||||
la façade transmet le signal à l'enfant et attend sa fin (moteur llama-server
|
||||
arrêté proprement par Go, comme aujourd'hui).
|
||||
|
||||
## Mise à jour au démarrage
|
||||
|
||||
### Publication (CI)
|
||||
|
||||
À chaque push sur `main`, en plus de l'image GHCR, un workflow publie la
|
||||
release GitHub **`rolling`** (pré-release, assets écrasés) :
|
||||
|
||||
- `loki-linux` — binaire Go (déjà `CGO_ENABLED=0`)
|
||||
- `loki-rs-linux` — binaire Rust musl
|
||||
- `SHA256SUMS` — format `sha256sum` (celui que `loki update` sait déjà lire)
|
||||
- `manifest.json` — `{"commit": "<sha>", "image_abi": <entier>}`
|
||||
|
||||
La release `rolling` n'est jamais marquée `latest` : `loki update` (machines
|
||||
non conteneurisées) continue de suivre les tags `vX.Y.Z`.
|
||||
|
||||
### ABI d'image
|
||||
|
||||
Le Dockerfile déclare `ENV LOKI_IMAGE_ABI=1`. On l'incrémente (Dockerfile et
|
||||
source du manifeste dans le même commit) quand le code exige un changement de
|
||||
l'image : nouvelle dépendance système, Python/Node, chemin du moteur, etc.
|
||||
Un binaire publié avec une ABI différente de celle de l'image n'est **jamais**
|
||||
installé : il ne pourrait pas tourner correctement.
|
||||
|
||||
### Au boot (`loki-rs self-update`)
|
||||
|
||||
Appelé par `docker-entrypoint.sh`, avec le `loki-rs` de l'image :
|
||||
|
||||
1. `LOKI_AUTO_UPDATE=0` → ne fait rien.
|
||||
2. Télécharge `manifest.json` (timeout global 20 s). Erreur réseau → étape 6.
|
||||
3. `image_abi` ≠ `LOKI_IMAGE_ABI` → journal :
|
||||
« nouvelle version nécessitant une nouvelle image : docker compose pull »,
|
||||
étape 6.
|
||||
4. `commit` identique à `/data/bin/VERSION` → rien à faire, étape 6.
|
||||
5. Télécharge les deux binaires en `/data/bin/*.part`, vérifie chacun contre
|
||||
`SHA256SUMS`, `chmod +x`, renomme atomiquement, écrit `VERSION` en dernier.
|
||||
Toute erreur → `.part` supprimés, rien remplacé.
|
||||
6. Fin. Code de sortie 0 dans tous les cas : le boot n'est jamais bloqué.
|
||||
|
||||
L'entrypoint choisit ensuite les binaires : `/data/bin/loki` et
|
||||
`/data/bin/loki-rs` s'ils existent, sont exécutables **et** que
|
||||
`/data/bin/ABI` égale `LOKI_IMAGE_ABI` ; sinon ceux de l'image. Les deux
|
||||
binaires viennent toujours de la même source (jamais un Go de l'image avec un
|
||||
Rust de `/data/bin`). Le chemin Go choisi est passé à la façade
|
||||
(`LOKI_GO_BIN`) et utilisé pour les appels `loki config` de l'entrypoint.
|
||||
|
||||
Limite assumée : seuls les binaires Loki se mettent à jour ainsi ; llama.cpp
|
||||
se met à jour via l'UI (existant), le reste de l'image via un pull signalé par
|
||||
l'ABI.
|
||||
|
||||
## Organisation du code Rust
|
||||
|
||||
```
|
||||
rust/
|
||||
Cargo.toml workspace
|
||||
loki-rs/
|
||||
src/main.rs CLI : serve | self-update | version
|
||||
src/proxy.rs façade HTTP/SSE/WebSocket
|
||||
src/child.rs supervision du processus Go
|
||||
src/update/ manifeste, téléchargement, SHA256SUMS, installation
|
||||
```
|
||||
|
||||
Dépendances : `tokio`, `axum`, `hyper`/`hyper-util`, `reqwest` (rustls,
|
||||
sans OpenSSL — musl), `sha2`, `serde`/`serde_json`, `clap`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unitaires : parse de `SHA256SUMS` (entrées `*nom`, nom absent), décision
|
||||
d'installation (ABI différente, même commit, `LOKI_AUTO_UPDATE=0`),
|
||||
installation atomique (échec au milieu → rien remplacé).
|
||||
- Intégration : façade devant un faux backend axum —
|
||||
SSE reçu morceau par morceau (pas en bloc), WebSocket écho,
|
||||
en-têtes `Host`/`Authorization`/`Origin` transmis intacts, `X-Loki-Via`
|
||||
entrant effacé, 502 si backend absent.
|
||||
- Mise à jour : faux serveur de release local (URL de base injectable par
|
||||
variable d'environnement de test).
|
||||
- CI : `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` dans un
|
||||
job dédié ; la CI Go (gofmt + staticcheck + tests) reste inchangée.
|
||||
|
||||
## Feuille de route des lots suivants (hors périmètre de ce spec)
|
||||
|
||||
| Lot | Contenu |
|
||||
|---|---|
|
||||
| 2 | Fichiers statiques de l'UI ; lecture de la config via une API interne Go |
|
||||
| 3 | Supervision de llama-server |
|
||||
| 4 | Client LLM, `/v1` OpenAI, flux chat SSE |
|
||||
| 5 | Outils de l'agent, MCP, mode Code/LSP |
|
||||
| 6 | Modèles et téléchargements, Strata, optimiseur, bench |
|
||||
| 7 | Mémoire, tâches, dictée, relais, CDP |
|
||||
| 8 | Store bbolt → redb, retrait de Go |
|
||||
|
||||
Chaque lot aura son propre spec, plan et PR.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Migration Rust — lot 2 : UI statique, authentification, `/api/ping`
|
||||
|
||||
Date : 2026-10-13 · Statut : validé · Prérequis : lot 1 (façade `loki-rs`).
|
||||
|
||||
## But
|
||||
|
||||
Faire passer en Rust les premières routes réelles, et surtout poser le socle
|
||||
dont toutes les routes `/api/*` suivantes auront besoin : l'authentification.
|
||||
|
||||
## Périmètre
|
||||
|
||||
1. **UI statique servie par Rust**, embarquée dans le binaire à la
|
||||
compilation depuis `internal/loki/ui/` (même source que le `go:embed`) :
|
||||
|
||||
| Route | Content-Type | Cache-Control | Autre |
|
||||
|---|---|---|---|
|
||||
| `/`, `/index.html` | `text/html; charset=utf-8` | `no-store, max-age=0` | |
|
||||
| `/marked.min.js` | `application/javascript` | `public, max-age=86400` | |
|
||||
| `/sw.js` | `application/javascript` | `no-store, max-age=0` | `Service-Worker-Allowed: /` |
|
||||
| `/manifest.webmanifest` | `application/manifest+json` | `public, max-age=3600` | |
|
||||
| `/fonts/<nom>.woff2` | `font/woff2` | `public, max-age=31536000, immutable` | |
|
||||
|
||||
GET et HEAD seulement (les autres méthodes restent relayées au Go).
|
||||
Compression gzip si le client l'accepte, sauf polices (comme
|
||||
`web_gzip.go`) ; `Vary: Accept-Encoding` sur les types compressibles.
|
||||
Version compressée calculée une fois, à la première demande.
|
||||
|
||||
2. **API interne Go** `GET /internal/v1/auth` → `{"web_key": "…", "version": "…"}`.
|
||||
- Servie **uniquement sur l'écoute TCP directe** de `loki web`
|
||||
(enveloppe autour du mux dans `cmdWeb`) : le tunnel du relais sert le mux
|
||||
nu et ne la voit jamais.
|
||||
- Exige l'en-tête `X-Loki-Internal` égal à `LOKI_INTERNAL_TOKEN` (≥ 32
|
||||
caractères, comparaison en temps constant). Jeton absent, trop court ou
|
||||
faux → 404 (rien ne révèle l'existence de la route).
|
||||
- Base illisible → 503 : la façade fermera l'accès (jamais ouvert par
|
||||
défaut sur une panne d'E/S, même règle que `requireWebAuth`).
|
||||
- La façade tire un jeton aléatoire de 32 octets à chaque démarrage et le
|
||||
passe au Go par l'environnement.
|
||||
|
||||
3. **Authentification de pilotage en Rust** (`auth.rs`), port à l'identique de
|
||||
`requireWebAuth` + `crossSiteReject` + `localHostName` + `checkBearer` :
|
||||
- clé relue à **chaque requête** (API interne) : un changement de clé prend
|
||||
effet immédiatement ;
|
||||
- API interne en erreur (Go absent, 503, réponse illisible) → 503
|
||||
`{"error":"configuration illisible — réessaie dans un instant"}` ;
|
||||
- refus site tiers / hôte non local sans clé → 403 `{"error":"<raison>"}`
|
||||
(mêmes messages que le Go) ;
|
||||
- Bearer absent ou faux → 401 `{"error":"non autorisé"}` +
|
||||
`WWW-Authenticate: Bearer realm="loki"` ;
|
||||
- `X-Loki-Via` n'est jamais pris en compte côté Rust (effacé à l'entrée,
|
||||
le trafic du tunnel ne traverse pas la façade).
|
||||
|
||||
4. **`/api/ping` en Rust** : `{"ok":true,"service":"loki","version":"<version Go>"}`
|
||||
après authentification — première route `/api/*` servie par Rust.
|
||||
|
||||
5. **Durcissement de la façade** : `/internal/*` venant de l'extérieur → 404 ;
|
||||
en-tête `X-Loki-Internal` entrant effacé avant relais.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
Toutes les autres routes `/api/*`, `/v1`, le flux chat : lots suivants.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go : route interne (sans jeton → 404, mauvais jeton → 404, bon jeton → 200
|
||||
avec clé et version, autres chemins relayés au mux).
|
||||
- Rust : cas de `TestCrossSiteReject` repris un pour un ; `local_host_name`
|
||||
(IP, IPv6 entre crochets, nom sans point, suffixes locaux, hôte de
|
||||
confiance) ; Bearer ; statiques (types, cache, gzip, HEAD, police inconnue
|
||||
→ relayée) ; intégration façade + faux backend interne : ping 200 sans clé,
|
||||
401 avec clé sans Bearer, 200 avec bon Bearer, 503 si l'API interne tombe,
|
||||
403 rebinding, `/internal/v1/auth` depuis l'extérieur → 404.
|
||||
- Bout en bout local : vrai `loki` Go derrière la façade, `loki set-web-key`,
|
||||
ping avec et sans clé.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Migration Rust — lot 3 : supervision du moteur
|
||||
|
||||
Date : 2026-10-14 · Statut : livré · Prérequis : lots 1-2.
|
||||
|
||||
## Découpage
|
||||
|
||||
Le « moteur » en conteneur, c'est deux couches de taille très différente :
|
||||
|
||||
1. **La supervision du processus** (~350 lignes Go : `sys_service_container.go`,
|
||||
`sys_proctree_linux.go`) — lancer `loki serve` détaché, fichier PID,
|
||||
journal, arrêter tout l'arbre (Strata compris) et attendre que la VRAM
|
||||
soit rendue.
|
||||
2. **La ligne de commande** de llama-server / Strata (~9 000 lignes :
|
||||
`backend_serve*.go`, presets, MoE, spéculatif, threads, cache…), là où
|
||||
s'applique la règle « ne jamais dénaturer les modèles ».
|
||||
|
||||
Le lot 3 porte la couche 1. La couche 2 reste au Go (`loki serve`) et fera
|
||||
l'objet d'un lot dédié, parce que chaque option y a été réglée contre un
|
||||
défaut mesuré.
|
||||
|
||||
## Ce qui change
|
||||
|
||||
- `loki-rs engine start|stop|restart|status` : port à l'identique de la
|
||||
supervision Go — chef de session (`setsid`), fichier PID et journal aux
|
||||
**mêmes chemins** (`$LOKI_HOME/<service>.pid|.log`), contrôle à 2 s avec
|
||||
les 20 dernières lignes du journal en cas d'échec, arrêt de l'arbre
|
||||
(session, groupe, filiation ; SIGTERM, 4 s, SIGKILL, 20 s au plus), mêmes
|
||||
messages.
|
||||
- Le Go garde ses garde-fous (optimisation en cours via `serviceAction`,
|
||||
preset externe) puis délègue au `loki-rs` voisin de son binaire.
|
||||
`loki logs`, `enable`/`disable` restent au Go.
|
||||
- Porte de sortie : `LOKI_ENGINE_SUPERVISOR=go`, ou absence de `loki-rs`.
|
||||
- Différence assumée : le contrôle « démarré ? » regarde si l'enfant est
|
||||
sorti (`try_wait`), pas le signal 0 — un enfant mort reste un zombie, qui
|
||||
répond encore au signal 0.
|
||||
- L'arrêt exclut de l'arbre `loki-rs` lui-même ET son parent (le Go qui l'a
|
||||
appelé), comme le Go excluait son propre PID.
|
||||
|
||||
## Tests
|
||||
|
||||
- Rust (toutes plateformes) : `/proc/<pid>/stat` avec parenthèses dans le
|
||||
nom, arbre par session/groupe/filiation avec exclusion de l'appelant.
|
||||
- Rust (Linux, CI) : faux `loki serve` qui lance un enfant dans sa session et
|
||||
un « moteur » dans SA PROPRE session (comme Strata) — démarrage, « déjà
|
||||
démarré », arrêt de tout l'arbre, redémarrage, échec avec journal, PID
|
||||
nettoyé.
|
||||
- Go (Linux, CI) : choix du `loki-rs` voisin (absent, non exécutable,
|
||||
`LOKI_ENGINE_SUPERVISOR=go`), appel réel avec l'environnement attendu,
|
||||
erreur rendue = dernière ligne d'erreur du superviseur.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Migration Rust — lot 6 : le cœur chat
|
||||
|
||||
Date : 2026-10-14 · Statut : **livré** (étapes 1 à 6 ; défaut Rust depuis le 2026-10-15, `LOKI_CHAT=go` pour revenir au Go) ·
|
||||
Prérequis : lots 1-5 (façade, auth, store via API interne, `/v1`).
|
||||
|
||||
## Pourquoi c'est un lot à part
|
||||
|
||||
Le chat est le cœur de Loki et le seul chemin qu'on ne peut pas casser un
|
||||
soir de déploiement. ~17 700 lignes Go (`chat_*.go` 11 491, `llm_*.go`
|
||||
6 245), dont une fonction de 1 469 lignes (`runChatLoop`,
|
||||
`llm_client.go:1279`) qui mêle protocole du moteur, reprises sur erreur,
|
||||
compactage en cours de tour et exécution des outils.
|
||||
|
||||
## Carte (HEAD b98ee5c)
|
||||
|
||||
### Surface HTTP
|
||||
Toutes derrière `requireWebAuth` (déjà portée).
|
||||
|
||||
| Route | Handler | Rôle |
|
||||
|---|---|---|
|
||||
| `/api/chat` | `handleChat` web_chat.go:316 | **SSE** : rejoue le journal puis le suit en direct |
|
||||
| `/api/chat/send` | `handleChatSend` :178 | démarre un tour ou le met en file (409 occupé, 503 pas prêt) |
|
||||
| `/api/chat/stop`, `/reset`, `/state`, `/compact`, `/tool-result` | web_chat.go | contrôle et lecture |
|
||||
| `/api/chat/mode`, `/criteria` | code_mode.go | mode Code |
|
||||
| `/api/chat/export`, `/upload`, `/files*`, `/file*`, `/image`, `/cushot` | divers | pièces jointes, espace de travail, aperçu navigateur |
|
||||
| `/api/conversations{,/new,/switch,/rename,/delete}` | web_chat.go | index des discussions |
|
||||
| `/api/e2e/chat` | relay_e2e.go:143 | même flux, chiffré AES-GCM par événement (tunnel) |
|
||||
|
||||
Trame SSE : `data: {"choices":[{"delta":…}]}`, battement `: ping` toutes les
|
||||
4 s, regroupement à 128 Kio ou 256 événements.
|
||||
|
||||
### État en mémoire
|
||||
- `conv *Conversation` (chat_conversation.go:114) : `Messages` (vue du
|
||||
modèle), `Log []LogEvent{Seq,TS,Delta}` (journal d'affichage, **seule voie
|
||||
vers l'UI**, via `appendDelta`), `Seq`, `CtxUsed`, `Generating`, `cancel`,
|
||||
`epoch`, file d'attente, tâche en cours. Un `mu` + `cond`.
|
||||
- `Generating` est un **verrou global du moteur** partagé avec les tâches
|
||||
planifiées (`RunAutonomous`), le bench et l'optimiseur (`measureLease`) et
|
||||
quatre sondes `isGenerating`.
|
||||
- Persistance : `snapshot()` sérialise Messages ET Log dans un seul JSON,
|
||||
**chiffré** (`memEncoderNow`, clé en mémoire du Go après déverrouillage),
|
||||
écrit par un écrivain unique (`persistQ`) en une transaction bbolt —
|
||||
bucket `chat`, clés `conv:<id>`, `index`, `active`.
|
||||
|
||||
### Boucle d'un tour
|
||||
`handleChatSend` → `EnqueueOrStart` → `StartTurn` → `go generate` →
|
||||
`turnView` (prompt système, outils, MCP, mémoire, instantané projet) →
|
||||
`runChatTools` → `runChatLoop` (payload, slot, requête au moteur, analyse du
|
||||
flux, reprises, compactage réactif, **switch des outils inline**
|
||||
llm_client.go:2363-2549, compactage en cours de tour) → retour dans
|
||||
`generate` (validation mode Code `codeVerifyLoop`, compactage de fin de
|
||||
tour, persistance, file suivante, préchauffage).
|
||||
|
||||
## Coupures possibles
|
||||
|
||||
| | Coupure | Ce qui traverse | Risque |
|
||||
|---|---|---|---|
|
||||
| A | Journal + SSE en Rust, boucle au Go | flux de deltas Go→Rust, epoch, reset | Log persisté DANS le même blob chiffré que Messages |
|
||||
| B | Client LLM en Rust (protocole, flux, reprises) | appel d'outil Go en RPC, porte du moteur, compactage | fonction de 1 469 lignes, appels récursifs (sous-agents, vérificateur) |
|
||||
| C | Machine d'état de la conversation en Rust | turnView, compactage, outils, persistance en RPC | verrou `Generating` partagé avec tâches/bench/optimiseur |
|
||||
| D | Sessions + persistance | — | écrivain unique bbolt, chiffrement, ordre des résultats d'outils |
|
||||
|
||||
## Stratégie proposée
|
||||
|
||||
Pas de bascule partielle de la discussion de l'utilisateur : le cœur chat
|
||||
est réécrit **en entier** côté Rust, derrière `LOKI_CHAT=rust` (Go par
|
||||
défaut), puis comparé au Go sur des conversations rejouées avant de devenir
|
||||
le défaut. Étapes, chacune livrée et testée :
|
||||
|
||||
1. **Go, sans changement de comportement** : extraire le switch des outils en
|
||||
`execTool(ctx, name, args, caps) → résultat` et l'exposer en API interne
|
||||
(`/internal/v1/tool/exec`), ainsi que le chiffrement des blobs
|
||||
(`/internal/v1/crypt/seal|open`, la clé reste au Go) et le bail du moteur
|
||||
(`Generating` → `/internal/v1/engine/lease`).
|
||||
2. **Rust, bibliothèque** : protocole du moteur (payload OpenAI, analyse SSE
|
||||
llama.cpp/Strata, reasoning, appels d'outils natifs et textuels, effort,
|
||||
reprises réseau), testé contre des flux enregistrés.
|
||||
3. **Rust, conversation** : Messages + Log + epoch + file + SSE `/api/chat`
|
||||
(rejeu, pagination `historyCut`, `coalesceReplay`, `compactLog`),
|
||||
persistance au même format (`conv:<id>`, chiffré via l'étape 1).
|
||||
4. **Rust, boucle** : `runChatLoop` réécrit en machine d'état explicite,
|
||||
outils exécutés par le Go (étape 1), compactage porté.
|
||||
5. **Parité** : rejeu de conversations réelles exportées, comparaison des
|
||||
journaux produits (Go vs Rust) avec un faux moteur déterministe.
|
||||
6. **Bascule** `LOKI_CHAT=rust` par défaut ; outils, MCP, mémoire, mode Code
|
||||
portés ensuite (lot 7) en retirant les appels au Go un par un.
|
||||
|
||||
## Questions ouvertes
|
||||
- Chiffrement : port du format (`memEncoderNow`) en Rust à l'étape 3, ou
|
||||
RPC au Go jusqu'au lot 9 ?
|
||||
- Tunnel du relais (`/api/e2e/chat`) : reste au Go jusqu'au portage du
|
||||
relais ; tant que `LOKI_CHAT=rust`, le flux chiffré doit lire le journal
|
||||
Rust (API interne inverse) — à concevoir.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Lot 10 — réglages partagés tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Quatre routes ne font que lire et écrire des réglages en base, partagés entre
|
||||
tous les appareils :
|
||||
|
||||
| Route | Port de | Ce qu'elle règle |
|
||||
|---|---|---|
|
||||
| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) |
|
||||
| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:<id>`), repli sur le global (`state/sysprompt`) |
|
||||
| `GET\|POST /api/reasoning-effort` | `handleReasoningEffort` | `REASONING_EFFORT` de la configuration vivante (liste blanche) |
|
||||
| `GET\|POST /api/oai/public` | `web_public_url.go` | adresse publique normalisée en « schéma://hôte[:port] » |
|
||||
|
||||
Lot 10 : la façade les sert quand `LOKI_SETTINGS=rust` (`settings.rs`,
|
||||
expérimental ; sans la variable rien ne change).
|
||||
|
||||
Les valeurs restent dans la base du Go, aux mêmes clés. Le Go les relit là
|
||||
où il s'en sert (tour de chat, accès OpenAI) : son cache de configuration se
|
||||
périme sur l'horodatage du fichier de base, qu'une écriture de la façade
|
||||
modifie. `facade_settings.go` remet les routes du tunnel à la façade.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Message d'un corps JSON syntaxiquement illisible : celui de serde. Une
|
||||
valeur du mauvais type (`{"text":5}`) garde le message d'`encoding/json`.
|
||||
- Préférences réécrites clé par clé au lieu d'une transaction.
|
||||
- Port nommé dans l'adresse publique (`:https`) refusé : le Go le résolvait
|
||||
par `/etc/services`.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_settings_test.go` tire du Go :
|
||||
|
||||
- 19 adresses publiques normalisées ou refusées ;
|
||||
- 11 valeurs d'intensité ;
|
||||
- 29 appels de route, avec le contenu de la base après chacun :
|
||||
- fusion des préférences (valeurs refusées, prénom tronqué, vides effacés) ;
|
||||
- prompt par preset avec repli, effacement, méthode refusée ;
|
||||
- intensité acceptée ou refusée ;
|
||||
- adresse publique.
|
||||
|
||||
`tests/settings.rs` rejoue le tout par la façade. Une mutation (prénom
|
||||
tronqué à 25 caractères) fait échouer le rejeu.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Lot 11 — fichiers de la discussion tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les fichiers rangés par discussion vivaient dans le Go :
|
||||
|
||||
- `web_upload.go` : dépôt, téléchargement, image ;
|
||||
- `web_files.go` : panneau Fichiers ;
|
||||
- `web_files_zip.go` : archive ;
|
||||
- `web_thumb.go` et `web_upload_orient.go` : vignettes ;
|
||||
- `chat_convfiles.go` : chemins.
|
||||
|
||||
Lot 11 : la façade les sert quand `LOKI_FILES=rust` (`convfiles.rs`,
|
||||
expérimental ; sans la variable rien ne change).
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `POST /api/chat/upload` | dépôt en morceaux base64 (8 Mio), fichier de 1 Gio au plus, espace disque vérifié, `.part` renommé à la fin sous un nom sûr et unique |
|
||||
| `GET /api/chat/files` | panneau : dossiers d'abord puis du plus récent au plus ancien, tailles récursives (liens non suivis), vue « hors discussion » |
|
||||
| `POST /api/chat/file/delete` | suppression ; refus de la racine et du dossier des discussions |
|
||||
| `GET /api/chat/file` | fiche, tranche base64 (tunnel E2E) ou brut en pièce jointe (plage d'octets) |
|
||||
| `GET /api/chat/image` | type reniflé sur le contenu, en ligne sous une CSP fermée ; vignette JPEG `thumb=<px>` redressée (EXIF) et réduite par moyennage |
|
||||
| `GET /api/chat/files/zip` | archive construite à la fiche, puis servie en tranches ou en brut |
|
||||
|
||||
Le dossier de la discussion active reste décidé par le Go : la façade le lui
|
||||
demande à chaque requête par la nouvelle route `/internal/v1/conv/workspace`
|
||||
(`{dir, root, id, title}`). Il connaît la discussion active, qu'il tienne la
|
||||
conversation ou que ce soit la façade. Les liens des anciens messages
|
||||
(`captures/<id>/…`, `uploads/…` à la racine) restent résolus comme avant.
|
||||
|
||||
`facade_files.go` remet les routes du tunnel à la façade. Il ne vide plus les
|
||||
`.part` d'envois au démarrage : la façade le fait au sien.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Octets des vignettes JPEG : l'encodeur diffère. Les dimensions, le
|
||||
redressement et la réduction (algorithme porté pixel pour pixel) sont les
|
||||
mêmes.
|
||||
- Horodatage des entrées `.zip` en UTC.
|
||||
- Une seule plage d'octets par requête de téléchargement : le Go en acceptait
|
||||
plusieurs, en multipart.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_files_test.go` tire du Go :
|
||||
|
||||
- 10 noms de dépôt et 6 slugs ;
|
||||
- 6 en-têtes EXIF ;
|
||||
- les 8 orientations d'une image de 5×3 et 4 réductions, pixel pour pixel ;
|
||||
- 10 reniflages ;
|
||||
- 42 appels de route sur une arborescence fixe, dates comprises :
|
||||
- panneau, téléchargement, image et vignette ;
|
||||
- dépôt en un morceau, en deux morceaux, envoi expiré, fichier vide,
|
||||
base64 illisible ;
|
||||
- suppressions ;
|
||||
- archive de la discussion et « hors discussion », contenu comparé
|
||||
entrée par entrée ;
|
||||
- les fichiers restants après chaque POST.
|
||||
|
||||
`tests/convfiles.rs` rejoue le tout par la façade, plus une plage d'octets,
|
||||
un lien vers l'extérieur (refusé, non compté) et le nom d'archive tiré du
|
||||
titre. Deux mutations (ordre du panneau, suffixe des doublons) font échouer
|
||||
le rejeu.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Lot 12 — notifications Web Push tenues par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les notifications « réponse prête » vivaient dans le Go : `push.go`,
|
||||
`web_push.go` et webpush-go. Lot 12 : la façade les tient quand
|
||||
`LOKI_PUSH=rust` (`push.rs`, expérimental ; sans la variable rien ne change).
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/push/key` | clé publique VAPID, générée au premier appel puis stable |
|
||||
| `POST /api/push/subscribe` | enregistre un abonnement (idempotent par endpoint, clés mises à jour) |
|
||||
| `POST /api/push/unsubscribe` | le retire |
|
||||
| `POST /internal/facade/v1/push/send` | (le Go, jeton interne) pousse une notification à tous les abonnés, en tâche de fond |
|
||||
|
||||
Les envois du Go passent tous par `sendPushToAll` : fin de tour du chat Go,
|
||||
fin de tour tenu par la façade (`/internal/v1/turn/done`), fin de tâche. Ils
|
||||
sont délégués à la façade. Clés et abonnements restent en base aux mêmes
|
||||
clés (`push_vapid_priv`, `push_vapid_pub`, `push_subs`).
|
||||
|
||||
Chiffrement RFC 8291 (`aes128gcm`), enregistrement complété à 4096 octets
|
||||
comme webpush-go. Signature VAPID RFC 8292 (JWT ES256, 12 h, sujet
|
||||
« mailto: »). TTL de 120 s, urgence haute. Un abonnement refusé pour de bon
|
||||
(404/410) est purgé. La cryptographie passe par ring, déjà présent via
|
||||
rustls. Une façade construite sans TLS répond que les notifications sont
|
||||
indisponibles.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Messages des journaux d'envoi au format des crates Rust.
|
||||
|
||||
## Parité
|
||||
|
||||
- `internal/loki/parity_push_test.go` tire du Go :
|
||||
- les durées du corps de notification ;
|
||||
- onze appels de route (clé, abonnements valides et refusés, mise à jour
|
||||
des clés, désabonnements), avec la liste en base après chacun.
|
||||
|
||||
`tests/push.rs` rejoue le tout par la façade. La liste en base est
|
||||
comparée à l'octet près, et la clé reste la même d'un appel à l'autre.
|
||||
- Le chiffrement reproduit l'exemple de l'annexe A de la RFC 8291 à l'octet
|
||||
près.
|
||||
- Un envoi de bout en bout vers un faux service de push vérifie :
|
||||
- les en-têtes ;
|
||||
- la signature VAPID, contrôlée avec la clé rangée ;
|
||||
- le corps, déchiffré côté navigateur avec la clé de l'abonnement ;
|
||||
- l'abonnement en 410, purgé.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Lot 13 — mémoire tenue par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La mémoire de Loki vivait dans le Go :
|
||||
- `chat_memory.go`, `mem_index.go`, `mem_io.go` et `mem_fsutil.go` pour les pages ;
|
||||
- `moveMemPage` (`projects.go`) pour le déplacement entre projets ;
|
||||
- `tracker.go` et `web_tracker.go` pour les trackers ;
|
||||
- les routes du mode agent.
|
||||
|
||||
Lot 13 : la façade tient ces routes quand `LOKI_MEM=rust` (`memory.rs`,
|
||||
expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/mem?name=` | contenu d'une page (modèle de page neuve sans nom) |
|
||||
| `POST /api/mem/save` | crée, écrase ou renomme une page ; l'index suit |
|
||||
| `POST /api/mem/delete` | supprime une page ; sa ligne d'index part avec elle |
|
||||
| `POST /api/mem/move` | déplace une page vers un autre projet ; les deux index suivent |
|
||||
| `GET /api/agent` (alias `/api/tools`, `/api/skills`) | mode agent, compactage, mode mémoire, liste des pages |
|
||||
| `POST /api/agent/toggle` (et alias) | mode agent (`state/agent`) |
|
||||
| `POST /api/agent/compact` | compactage automatique (`COMPACT`) |
|
||||
| `GET|POST /api/memory` | mode mémoire du projet actif, repli sur `MEM_MODE` |
|
||||
| `GET|POST /api/tracker` | panneau des trackers : liste, détail, ajout, modification, suppression, renommage, déplacement |
|
||||
|
||||
### Disque et base
|
||||
|
||||
Les pages restent des fichiers `memory/<projet>/<nom>.md`, et l'index
|
||||
`MEMORY.md` reste en clair. Le Go continue de les lire et de les écrire pour
|
||||
sa propre boucle : outils `mem_*`, index injecté au modèle. Les écritures
|
||||
restent sûres, comme dans le Go :
|
||||
- un `.bak` de la version précédente est gardé ;
|
||||
- le fichier est écrit dans un temporaire, synchronisé, puis renommé ;
|
||||
- le contenu est relu et comparé octet par octet.
|
||||
|
||||
Les trackers restent dans le bucket `tracker`, sous la clé
|
||||
`<projet>/<slug>`. Le registre des projets (`state/projects`) est lu tel
|
||||
quel. Le mode mémoire y est écrit avec l'échappement HTML de
|
||||
`encoding/json`, pour garder les mêmes octets en base.
|
||||
|
||||
### Chiffrement
|
||||
|
||||
La clé de la mémoire ne quitte pas le Go :
|
||||
- une page qui porte le magic `LOKIMEMv1` est ouverte par
|
||||
`/internal/v1/crypt/open` ;
|
||||
- toute écriture de page ou de tracker passe par `/internal/v1/crypt/seal`,
|
||||
qui rend le clair tel quel quand le chiffrement est coupé.
|
||||
|
||||
Quand la mémoire est verrouillée :
|
||||
- les pages restent listées avec le titre « 🔒 (chiffré — mémoire
|
||||
verrouillée) » ;
|
||||
- les trackers illisibles sont tus ;
|
||||
- aucune écriture ne retombe en clair.
|
||||
|
||||
### Messages d'erreur JSON
|
||||
|
||||
Ces routes renvoient au client le message d'erreur de décodage du Go.
|
||||
`gojson.rs` reproduit `json.NewDecoder(r.Body).Decode(&req)` :
|
||||
- seule la première valeur compte ;
|
||||
- `EOF` et `unexpected EOF` ;
|
||||
- les erreurs du scanner (`invalid character …`) ;
|
||||
- les erreurs de type, champs anonymes compris ;
|
||||
- la casse des noms de champs, et l'allocation des pointeurs.
|
||||
|
||||
`gojson.rs` est mutualisé pour les lots suivants.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
- Le coffre et le chiffrement (`/api/mem/encrypt|decrypt|unlock|lock`).
|
||||
- La santé, les instantanés, la sauvegarde.
|
||||
- Le hub des projets (`/api/projects`) : sa bascule recharge la discussion.
|
||||
- La bascule temporaire de projet d'une tâche planifiée. Elle n'existe qu'en
|
||||
RAM du Go : la façade sert toujours le projet actif rangé.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Un corps JSON syntaxiquement valide mais qui fait échouer serde (UTF-8
|
||||
invalide, nombre hors limites) donne le message de serde.
|
||||
- Une date d'un tracker qui tombe dans un trou d'heure d'été est résolue par
|
||||
`mktime`.
|
||||
|
||||
## Parité
|
||||
|
||||
### Décodage JSON
|
||||
|
||||
`internal/loki/parity_gojson_test.go` tire du Go 81 corps décodés : champs
|
||||
et message d'erreur. `tests/gojson.rs` les rejoue.
|
||||
|
||||
### Mémoire
|
||||
|
||||
`internal/loki/parity_mem_test.go` tire du Go :
|
||||
- les noms de pages, les titres, les slugs et les modes mémoire ;
|
||||
- 36 dates de trackers, lues dans un fuseau fixe (UTC+2) ;
|
||||
- 102 appels de route, avec après chacun le dossier `memory/` complet
|
||||
(`.bak` compris) et les clés touchées en base (mode agent, compactage,
|
||||
mode mémoire, projets, trackers).
|
||||
|
||||
Ces appels couvrent :
|
||||
- les pages : création, renommage, réenregistrement qui perd l'accroche,
|
||||
noms refusés, fichiers cachés et dossiers ignorés ;
|
||||
- le déplacement, avec ses six refus ;
|
||||
- la bascule de projet ;
|
||||
- le mode agent et le compactage ;
|
||||
- le mode mémoire, y compris un projet actif inconnu ;
|
||||
- les trackers : ajout, modification, suppression, renommage, déplacement,
|
||||
valeurs illisibles et événements nuls.
|
||||
|
||||
`tests/memory.rs` rejoue le tout par la façade, avec le même masquage des
|
||||
identifiants et des instants « maintenant ».
|
||||
|
||||
Une mutation de chaque harnais fait échouer son rejeu.
|
||||
|
||||
Un essai de bout en bout simule le chiffrement par le faux Go :
|
||||
- les pages sont écrites scellées et relues en clair ;
|
||||
- l'index reste en clair ;
|
||||
- les trackers sont scellés ;
|
||||
- une fois la mémoire verrouillée, rien n'est réécrit en clair.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 14 — `loki serve` composé en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 3, la supervision du moteur est en Rust : `loki-rs engine
|
||||
start|stop|restart`. Mais elle lançait encore `loki serve` (Go), qui compose
|
||||
la ligne de commande de llama-server avant de lui céder la place.
|
||||
|
||||
Cette ligne vit dans `backend_serve*.go`, environ 4 000 lignes. Chaque option
|
||||
y a été réglée contre un défaut mesuré :
|
||||
- couches GPU et placement automatique (`--fit`, `--fit-target`) ;
|
||||
- threads et sonde de conteneur ;
|
||||
- files de lancement CUDA ;
|
||||
- second slot, parallélisme de tenseurs ;
|
||||
- cache de prompts, points de reprise des hybrides ;
|
||||
- décodage spéculatif (MTP, brouillon, n-grammes) ;
|
||||
- garde-fous de fidélité, garde-fou de chargement (LOAD_GUARD) ;
|
||||
- état des slots gardé à la bascule.
|
||||
|
||||
Lot 14 : `loki-rs engine serve` fait ce travail quand `LOKI_SERVE=rust`
|
||||
(expérimental). Le superviseur le lance à la place de `loki serve`. Sans la
|
||||
variable, rien ne change.
|
||||
|
||||
## Découpage
|
||||
|
||||
- `serve/` : port fonction par fonction. `mod.rs` contient
|
||||
`build_serve_args` et les outils de drapeaux. Les modules suivent les
|
||||
fichiers Go :
|
||||
- `threads`, `queues`, `expert`, `fidelity` ;
|
||||
- `side`, `split`, `cache`, `ckpt` ;
|
||||
- `spec`, `moe`, `persist`.
|
||||
|
||||
Tout ce qui décide est pur.
|
||||
- `serve/run.rs` porte `cmdServe`. Il lit la configuration, la clé d'API, les
|
||||
dossiers de modèles et le jeton de tentative de SPEC=auto par l'API interne
|
||||
du Go. Il sonde ensuite :
|
||||
- l'aide du moteur, `--version`, `--list-devices` (bornés dans le temps,
|
||||
avec les bibliothèques du moteur) ;
|
||||
- le GGUF ;
|
||||
- la RAM, la VRAM (nvidia-smi), le cgroup et le cpuset ;
|
||||
- les variables `LLAMA_ARG_*` et `GGML_*`.
|
||||
|
||||
Il vérifie que le port est libre et écrit le marqueur de SLOT_PERSIST. Il
|
||||
garde la commande en base pour l'interface (`engine_cmdline`), puis fait
|
||||
`exec` du moteur.
|
||||
- La façade passe au Go l'adresse de son API interne (`LOKI_GO_ADDR`), à côté
|
||||
du jeton. Le superviseur, lancé par le Go, en hérite, et `engine serve` aussi.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
`loki serve` (Go) est lancé à notre place dans ces cas, à l'identique :
|
||||
- un preset Strata (son propre serveur, lot à venir) ;
|
||||
- le moteur d'essai de l'optimiseur (`LOKI_TUNE_TRIAL`) ;
|
||||
- l'absence d'API interne (lancement à la main hors de la façade).
|
||||
|
||||
Restent aussi au Go les aperçus de l'éditeur de preset (`/api/preset/cacheram`,
|
||||
`/api/preset/moe`, « Dupliquer en placement auto »). Ils viendront avec le lot
|
||||
des presets.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Le message d'un brouillon (`MODEL_DRAFT`) introuvable cite l'erreur système
|
||||
au format Rust.
|
||||
- Pas de cache de l'aide ni du build par binaire : `engine serve` ne compose
|
||||
qu'une ligne par lancement, le cache du Go ne servait qu'au process web.
|
||||
- Le jeton de tentative de SPEC=auto est lu puis réécrit par l'API interne,
|
||||
sans transaction : le Go seul l'efface, une fois le moteur prêt.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_serve_test.go` tire du Go 1 872 lancements, chacun
|
||||
avec sa ligne de commande, son environnement et ses notes. Les lancements
|
||||
croisent :
|
||||
- 12 machines (VRAM, RAM, GGUF dense, MoE, hybride, par couches, architecture
|
||||
exclue, brouillons, mémoire unifiée, conteneur à l'étroit) ;
|
||||
- 112 presets ;
|
||||
- 3 aides de moteur (récent, ancien, inconnu) ;
|
||||
- 13 jeux de variables `LLAMA_ARG_*`, et des variables `GGML_*`.
|
||||
|
||||
Le journal comprend aussi les sondes pures :
|
||||
- listes de CPU, cpuset et quotas, mémoire du cgroup ;
|
||||
- sortie de nvidia-smi, `--list-devices`, build du moteur ;
|
||||
- forme copiable de la commande ;
|
||||
- verdicts de SPEC=auto ;
|
||||
- drapeaux de chargement, taille d'état par jeton.
|
||||
|
||||
`tests/serve.rs` rejoue le tout contre `serve::build_serve_args`, argument
|
||||
par argument. Une mutation du calcul du cache, des files CUDA ou des bornes
|
||||
des n-grammes fait échouer le rejeu.
|
||||
|
||||
Deux essais de bout en bout lancent le vrai `loki-rs engine serve` contre un
|
||||
faux Go et un faux llama-server. Ils vérifient :
|
||||
- la ligne exécutée et le chemin des bibliothèques ;
|
||||
- la commande gardée en base ;
|
||||
- le refus d'un port pris, d'un modèle absent ;
|
||||
- la sortie propre d'un preset externe ;
|
||||
- la délégation au Go (sans API interne, essai de l'optimiseur, Strata).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Lot 15 — presets tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les presets vivaient dans le Go :
|
||||
- `backend_presets.go` pour l'écriture ;
|
||||
- les handlers de `web_api.go` et `web_external.go` ;
|
||||
- les aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`,
|
||||
`moePreview`, `autoPlacementPreset`).
|
||||
|
||||
Lot 15 : la façade tient ces routes quand `LOKI_PRESETS=rust`
|
||||
(`presets_api.rs`, expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/presets` | liste, avec pastilles (contexte, vision, quantification, raisonnement, Strata, externe) et mesure du bench si elle correspond à la configuration actuelle |
|
||||
| `POST /api/presets/order` | ordre choisi par glisser-déposer |
|
||||
| `GET /api/preset?id=` | contenu et prompt système ; sans id, la graine d'un preset neuf (BIN personnalisé, HOST, PORT) |
|
||||
| `POST /api/preset/save` | crée (nom de fichier unique) ou met à jour ; le preset EN SERVICE est appliqué tout de suite |
|
||||
| `POST /api/preset/delete` | refusé pour le preset actif ; emporte prompt système et mesure, et le modèle sur demande |
|
||||
| `POST /api/preset/cacheram`, `/api/preset/moe` | aperçus de l'éditeur, calculés comme au lancement (lot 14) |
|
||||
| `POST /api/preset/autoplace` | copie en placement automatique d'un preset MoE placé à la main |
|
||||
| `GET /api/preset/external?id=`, `POST /api/preset/external/save`, `/api/preset/external/test` | presets externes ; la clé n'est jamais rendue, le test fait un vrai appel de complétion |
|
||||
| `POST /api/switch` | bascule de preset |
|
||||
|
||||
Les presets restent des fichiers `presets/*.env`, et leur prompt système une
|
||||
clé de la base. Les aperçus réutilisent le port de `loki serve` (lot 14),
|
||||
sondes comprises.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Tout ce qui touche au moteur reste au Go, jusqu'au lot qui le portera. Le Go
|
||||
expose deux routes internes pour cela (`facade_presets.go`).
|
||||
|
||||
`POST /internal/v1/preset/apply {path, mode}` applique un preset à la
|
||||
configuration vivante :
|
||||
- `switch` : l'état du slot est relevé avant (SLOT_PERSIST), puis le moteur
|
||||
est relancé, ou arrêté pour un preset externe ;
|
||||
- `save` : application, puis relance ;
|
||||
- `apply` : la configuration seule (preset externe en service modifié).
|
||||
|
||||
`GET /internal/v1/tune/guard` dit si une optimisation tient le moteur. La
|
||||
réponse est alors un 409 `{ok: false, tuning: true}`, comme le Go.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- La suppression d'un preset retire sa mesure sans réécrire les autres avec
|
||||
leurs champs à zéro, comme le faisait la struct du Go. Le Go les relit
|
||||
pareil.
|
||||
- Le message d'un échec de réseau du test externe est celui de reqwest.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_presets_test.go` tire du Go deux séries.
|
||||
|
||||
Les fonctions pures :
|
||||
- nom d'affichage, guillemets, format `.env`, noms refusés ;
|
||||
- quantification, raisonnement, modèle ;
|
||||
- URL de complétions, contenu d'un preset externe, message d'erreur d'API ;
|
||||
- arguments rejoints, clé réécrite dans un preset ;
|
||||
- huit copies en placement auto ;
|
||||
- empreinte de mesure, moteur imposé par un preset.
|
||||
|
||||
Puis 70 appels de route, avec après chacun les dossiers `presets/` et
|
||||
`models/`, et les clés touchées en base. Ils couvrent :
|
||||
- créations, doublons, noms refusés, erreurs JSON ;
|
||||
- ordre, pastilles et mesures (empreinte, repli sur le modèle, autre
|
||||
configuration) ;
|
||||
- preset actif et suppressions (modèle compris, partagé, absent) ;
|
||||
- presets externes et leur test contre un faux service (succès, 401 au
|
||||
format OpenAI, 404 brut, 500 vide) ;
|
||||
- placement auto et aperçus.
|
||||
|
||||
`tests/presets_api.rs` rejoue le tout par la façade, et une mutation fait
|
||||
échouer le rejeu.
|
||||
|
||||
La bascule et l'enregistrement du preset en service sont vérifiés de bout en
|
||||
bout contre le faux Go :
|
||||
- appels d'application, avec leur mode ;
|
||||
- refus quand une optimisation tourne ;
|
||||
- erreur d'application rendue ;
|
||||
- prompt système.
|
||||
|
||||
Côté Go, `TestFacadePresetApply` vérifie `presetApply` : configuration
|
||||
appliquée, appareils préservés, moteur relancé, chemin hors du dossier
|
||||
refusé.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Lot 16 — pilotage du moteur tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le Go pilotait le moteur depuis l'interface :
|
||||
- `svcHandler` (`web_api.go`) pour `/api/start`, `/api/stop` et
|
||||
`/api/restart` ;
|
||||
- `handleVramUnload` et `handleVramReload` (`web_vram.go`) pour rendre la
|
||||
carte graphique à la machine, puis la reprendre ;
|
||||
- `serviceAction` (`sys_service.go`) : le verrou de l'optimiseur, puis, en
|
||||
conteneur, le superviseur Rust `loki-rs engine <action>` (lot 3,
|
||||
`sys_service_rust_linux.go`) ;
|
||||
- `preflightEngine` : ce sans quoi le moteur ne peut pas démarrer, dit avant
|
||||
de le lancer.
|
||||
|
||||
Lot 16 : la façade tient ces routes quand `LOKI_ENGINE=rust`
|
||||
(`enginectl.rs`, expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `/api/start`, `/api/stop`, `/api/restart` | pilotage du moteur ; réponse `{ok, out}` comme le Go |
|
||||
| `POST /api/vram/unload` | arrête le moteur et la dictée, rapporte la VRAM rendue ; refusé pendant une réponse en cours sauf `{force: true}` |
|
||||
| `POST /api/vram/reload` | relance le moteur après le contrôle d'avant démarrage ; `{already: true}` s'il tourne |
|
||||
|
||||
La façade lance elle-même `loki-rs engine <action>`, avec l'environnement que
|
||||
le Go lui passait (`LOKI_GO_BIN`, `LOKI_HOME`, `LOKI_SERVICE`) et de quoi
|
||||
joindre l'API interne du Go pour `loki-rs engine serve` (`LOKI_GO_ADDR`,
|
||||
`LOKI_INTERNAL_TOKEN`). Sa dernière ligne d'erreur devient l'erreur rendue,
|
||||
sans le préfixe du binaire. Un preset externe ne lance rien ; un redémarrage
|
||||
arrête alors le moteur local.
|
||||
|
||||
Le bilan de VRAM suit `gpuSettle` :
|
||||
- une lecture ratée est sautée, jamais comptée « 0 Mo » ;
|
||||
- un palier ne conclut qu'une fois la baisse commencée ;
|
||||
- les jauges du moniteur sont invalidées après coup.
|
||||
|
||||
## Quand la façade ne tient pas ce lot
|
||||
|
||||
La façade ne prend ce lot qu'en conteneur, avec son superviseur. Sous
|
||||
systemd, ou avec `LOKI_ENGINE_SUPERVISOR=go`, `loki-rs` ne passe pas
|
||||
`LOKI_ENGINE` au Go, qui garde les routes (décidé au démarrage, raison dans
|
||||
le journal).
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le Go expose trois routes internes (`facade_engine.go`) :
|
||||
- `POST /internal/v1/service/prestart` : avant tout démarrage, refus pendant
|
||||
une optimisation, puis ménage d'un verrou périmé (essai orphelin arrêté,
|
||||
application interrompue défaite). Le verrou de l'optimiseur suit le
|
||||
processus qui le tient, il reste au Go jusqu'au lot de l'optimiseur ;
|
||||
- `GET /internal/v1/chat/generating` : la réponse en cours, quand le Go tient
|
||||
la conversation (sinon la façade lit la sienne) ;
|
||||
- `POST /internal/v1/dictate/stop` : l'arrêt de la dictée, quand le Go la
|
||||
tient (sinon la façade arrête la sienne).
|
||||
|
||||
`GET /internal/v1/tune/guard` (lot 15) sert le refus 409 du rechargement.
|
||||
|
||||
Les autres appelants de `serviceAction` côté Go (bascule de preset,
|
||||
optimiseur, mises à jour du moteur) passent toujours par le Go. Les deux voies
|
||||
lancent le même superviseur, sur les mêmes fichiers PID et journal.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- `engineNeedsStop` regarde aussi les états transitoires de systemd. En
|
||||
conteneur, seul cas où la façade tient ce lot, il se réduit au PID vivant.
|
||||
- Un superviseur tué par un signal est décrit comme par le Go (« signal:
|
||||
killed ») pour les signaux courants, par son numéro sinon.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_engine_test.go` tire du Go :
|
||||
- dix bilans de VRAM (palier avant la baisse, lectures ratées, VRAM en
|
||||
hausse…) ;
|
||||
- la dernière ligne d'erreur du superviseur et le retrait des couleurs ;
|
||||
- vingt-six installations passées au contrôle d'avant démarrage (preset
|
||||
externe, Strata, BIN absent ou relatif, MODEL absent, hors des dossiers,
|
||||
remontant, en tranches incomplètes…) ;
|
||||
- dix réponses de route qui ne lancent rien (GET refusé avec `Allow: POST`,
|
||||
optimisation ou réponse en cours, moteur déjà arrêté, contrôle en échec).
|
||||
|
||||
`tests/enginectl.rs` rejoue le tout par la façade. Il vérifie aussi de bout en
|
||||
bout, avec un faux superviseur et un faux nvidia-smi :
|
||||
- démarrage, arrêt, redémarrage, refus pendant une optimisation ;
|
||||
- échec du démarrage, preset externe ;
|
||||
- déchargement chiffré, arrêt en échec, VRAM en hausse ;
|
||||
- rechargement.
|
||||
|
||||
Une mutation de chaque règle fait échouer les tests.
|
||||
|
||||
Côté Go, `TestFacadeMoteur` vérifie le relais et les trois routes internes.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lot 17 — interrupteurs système tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Quatre réglages de l'interface vivaient dans le Go :
|
||||
- l'accès internet de l'IA : `handleInternet`, `chat_internet.go`,
|
||||
`web_fetch_go.go` (moteur web, serveur Crawl4AI, clé, joignabilité) ;
|
||||
- le pilotage du navigateur : `handleComputer`, `chromePath`,
|
||||
`visionEnabled` ;
|
||||
- la clé de complétion du moteur : `handleAPIKey`, `backend_config.go`,
|
||||
`oaiBaseURL` ;
|
||||
- l'écoute réseau du moteur : `handleNetwork`, `sys_network.go`.
|
||||
|
||||
Lot 17 : la façade tient ces routes quand `LOKI_SYSTEM=rust` (`system.rs`,
|
||||
expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET\|POST /api/internet` | interrupteur, moteur web (`go` ou `crawl4ai`), URL et clé du serveur Crawl4AI (la clé n'est jamais rendue, seulement ses quatre derniers caractères), joignabilité |
|
||||
| `GET\|POST /api/computer` | interrupteur du navigateur, binaire trouvé, vision du modèle |
|
||||
| `GET\|POST /api/apikey` | clé de complétion : génération, saisie, effacement ; adresse `/v1` à coller dans un client tiers |
|
||||
| `GET\|POST /api/network` | moteur ouvert au réseau ou à cette machine seulement (`HOST`), adresse de l'endpoint |
|
||||
|
||||
Les réglages restent des clés de la base, écrites comme le Go : une chaîne
|
||||
vide efface la clé, et l'URL comme les clés ne laissent rien traîner dans la
|
||||
configuration.
|
||||
|
||||
La joignabilité de Crawl4AI suit `crawlReachable` :
|
||||
- `/health`, puis `/`, en moins de deux secondes en tout ;
|
||||
- clé envoyée en `Authorization: Bearer` ;
|
||||
- moins de 500 = joignable ;
|
||||
- cache de 30 s par URL, vidé quand l'URL, la clé ou le moteur changent.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le Go expose trois routes internes (`facade_system.go`) :
|
||||
- `POST /internal/v1/web/reach/reset` : le cache de joignabilité des outils
|
||||
web, encore exécutés par le Go, est vidé à chaque changement ;
|
||||
- `POST /internal/v1/cdp/shutdown` : le Chromium des outils `browser_*` se
|
||||
ferme quand l'interrupteur se coupe ;
|
||||
- `POST /internal/v1/service/action {action, if_active}` : une clé neuve ne
|
||||
s'applique qu'au redémarrage du moteur. La façade le relance elle-même si
|
||||
elle le pilote (lot 16), sinon le Go, seulement s'il tourne.
|
||||
|
||||
`GET /internal/v1/tune/guard` (lot 15) refuse un changement de clé pendant une
|
||||
optimisation.
|
||||
|
||||
Le relais du tunnel vers la façade garde désormais l'hôte et
|
||||
`X-Forwarded-Proto` du client : l'adresse de la clé de complétion est celle
|
||||
que le client a tapée, pas celle de la façade.
|
||||
|
||||
## Correction en passant
|
||||
|
||||
`maskAPIKey` paniquait sur une clé d'un seul octet (`k[len(k)-2:]`).
|
||||
`/api/apikey` tombait alors à chaque lecture tant que la clé restait en place.
|
||||
Une clé d'un ou deux octets s'affiche maintenant « … », côté Go comme côté
|
||||
Rust.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Hors Windows, aucun pare-feu n'est piloté : la façade ne tourne qu'en
|
||||
conteneur Linux, où le Go répondait déjà « inconnu ».
|
||||
- La clé masquée est coupée sur les octets, comme le Go. Un caractère coupé
|
||||
devient U+FFFD, octet par octet, comme son encodeur JSON.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_system_test.go` tire du Go 61 appels de route, avec
|
||||
après chacun les clés touchées en base et les en-têtes reçus par trois faux
|
||||
serveurs Crawl4AI (200, 500, 404). Ils couvrent :
|
||||
- moteurs inconnus, URL avec et sans slashs, injoignable, sans schéma ;
|
||||
- clés longues, courtes, d'un caractère, résiduelle en configuration ;
|
||||
- erreurs de type qui laissent passer les autres champs, `null` ;
|
||||
- vision par projecteur, preset externe ou Strata ;
|
||||
- clé générée (masquée dans le journal), saisie, de treize octets, repli sur
|
||||
`config.env`, adresse publique, `X-Forwarded-Proto`, refus pendant une
|
||||
optimisation ;
|
||||
- écoute ouverte, fermée, `localhost`, `::1`, port illisible.
|
||||
|
||||
Il tire aussi huit dispositions du dossier de Playwright pour le Chromium
|
||||
retenu.
|
||||
|
||||
`tests/system.rs` rejoue le tout par une seule façade, cache de
|
||||
joignabilité compris. Il vérifie de bout en bout ce qui reste au Go : cache
|
||||
vidé, navigateur fermé, moteur relancé par le Go, ou par le superviseur
|
||||
quand la façade pilote le moteur. Une mutation de chaque règle fait échouer
|
||||
les tests.
|
||||
|
||||
Côté Go :
|
||||
- `TestFacadeSysteme` vérifie le relais et les trois routes internes ;
|
||||
- `TestFacadeRelaisGardeHote` vérifie l'hôte gardé ;
|
||||
- `TestMaskAPIKeyCourte` vérifie le masque des clés courtes.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Lot 18 — hub des projets tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le hub des projets vivait dans le Go :
|
||||
- `handleProjects` et `projectDTOs` (`web_projects.go`) ;
|
||||
- les mutations de `projects.go` : `createProject`, `renameProject`,
|
||||
`setProjectDesc`, `deleteProject` ;
|
||||
- `projectSwitch` (`chat_sessions.go`).
|
||||
|
||||
Lot 18 : la façade tient `/api/projects` quand `LOKI_PROJECTS=rust`
|
||||
(`projects_api.rs`, expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Action | Rôle |
|
||||
|---|---|
|
||||
| `GET` | les projets, le plus ancien d'abord, avec leurs discussions, pages (`.md` visibles, index exclu) et trackers comptés ; le projet actif |
|
||||
| `create` | slug translittéré (`Réseau NAS` → `reseau-nas`), unique (`-2`, `-3`…), `projet` à défaut, 40 octets au plus ; nom de 60 runes au plus ; dossier `memory/<slug>` et son index amorcé |
|
||||
| `rename` | le libellé seul : slug, chemins et discussions ne bougent pas |
|
||||
| `desc` | 2 000 runes au plus, vide = effacée |
|
||||
| `switch` | la conversation passe sur le projet (la plus récente de ses discussions, ou une neuve) |
|
||||
| `delete` | discussions, trackers et mémoire du projet ; le projet actif est réassigné ; le dernier projet est indestructible |
|
||||
|
||||
Le registre reste dans la base (`state/projects`, écrit comme `json.Marshal`
|
||||
avec le HTML échappé), la mémoire de chaque projet dans `memory/<slug>`.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le projet actif passe toujours par le Go (`/internal/v1/project/activate`),
|
||||
qui en garde une copie en RAM pour la mémoire, les outils et les tâches.
|
||||
|
||||
Les discussions sont à qui tient la conversation.
|
||||
|
||||
Quand la façade la tient (`LOKI_CHAT=rust`), elle bascule et supprime
|
||||
elle-même. Avant de supprimer le projet actif, elle enregistre sa discussion
|
||||
et en ouvre une du premier projet restant.
|
||||
|
||||
Sinon le Go garde deux routes internes (`facade_projects.go`) :
|
||||
- `POST /internal/v1/project/switch` : `projectSwitch` (discussion du projet
|
||||
quitté enregistrée, plus récente du projet rejoint ouverte, ou une neuve) ;
|
||||
- `POST /internal/v1/project/drop-convs` : les discussions du projet
|
||||
supprimées (`convDelete`, fichiers et résultats compris).
|
||||
|
||||
Une génération en cours refuse la bascule et la suppression (409). La façade
|
||||
lit la sienne, ou celle du Go (`/internal/v1/chat/generating`, lot 16).
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Pendant une tâche planifiée, le Go voit comme actif le projet de la tâche ;
|
||||
la façade lit le projet actif enregistré.
|
||||
- Pendant un tour de la façade, une bascule vers un projet inconnu répond 409,
|
||||
comme quand le Go tient la conversation. Le Go, relayant à la façade,
|
||||
répondait 400 (« projet introuvable ») dans ce cas.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_projects_test.go` part d'une base amorcée :
|
||||
- pages visibles, cachées, hors `.md`, dans un sous-dossier ;
|
||||
- discussions de Générale, d'avant les projets, d'un projet à venir et d'un
|
||||
projet disparu ;
|
||||
- trackers, dont `alphabet/x` qui ne doit pas tomber avec `alpha`.
|
||||
|
||||
Il tire du Go 40 appels, avec après chacun le registre, le projet actif,
|
||||
l'arbre `memory/`, les trackers et l'index des discussions. Ils couvrent :
|
||||
- créations (doublons, translittération, sans lettres, longues, vides) ;
|
||||
- renommages et descriptions (longues, coupées sur une espace, effacées) ;
|
||||
- bascules (sur l'actif, inconnue, vers un projet sans discussion, pendant
|
||||
une génération) ;
|
||||
- suppressions (de l'actif, inconnue, pendant une génération, jusqu'au
|
||||
dernier projet) ;
|
||||
- actions inconnues, corps vide ou illisible, erreur de type, méthode
|
||||
refusée.
|
||||
|
||||
`tests/projects_api.rs` rejoue le tout par la façade, contre un faux Go qui
|
||||
bascule et supprime les discussions comme le vrai. La bascule et la
|
||||
suppression quand la façade tient la conversation sont vérifiées de bout en
|
||||
bout. Une mutation de chaque règle fait échouer les tests.
|
||||
|
||||
Côté Go, `TestFacadeProjets` vérifie le relais et les deux routes internes.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Lot 19 — export, mode Code et critères de la discussion
|
||||
|
||||
## Contexte
|
||||
|
||||
Avec `LOKI_CHAT=rust`, la façade tient la conversation (lot 6), mais trois
|
||||
routes de la discussion ouverte restaient au Go :
|
||||
- `/api/chat/export` (`chat_export.go`). Il relisait la copie enregistrée,
|
||||
qui peut avoir un temps de retard sur la conversation vivante ;
|
||||
- `/api/chat/mode` (`code_mode.go`) : mode Chat ou Code de la discussion, et
|
||||
ses critères ;
|
||||
- `/api/chat/criteria` : critères édités à la main.
|
||||
|
||||
Lot 19 : ces trois routes rejoignent celles de la conversation. La façade
|
||||
les sert quand elle la tient (`chatx.rs`, `facadeChatRoutes` côté Go), sans
|
||||
nouvelle variable. Sans `LOKI_CHAT=rust`, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/chat/export` | Markdown (fait pour être lu) ou JSON (fait pour être retraité), tiré de la conversation vivante ; options `format`, `reasoning`, `tools`, `results`, `turns` |
|
||||
| `GET\|POST /api/chat/mode` | mode de la discussion ouverte (`chat/mode:<id>`, absent = chat) et ses critères ; un changement part sur le fil (`{mode}`) |
|
||||
| `POST /api/chat/criteria` | critères édités : vides écartés, vingt au plus, ids posés, statuts ramenés à `pending`/`passed`/`failed` ; scellés comme le contenu des discussions (`chat/crit:<id>`), sur le fil (`{criteria, ts}`) |
|
||||
|
||||
L'export suit `chat_export.go` :
|
||||
- Markdown : un bloc par tour, le raisonnement replié dans un `<details>`,
|
||||
les outils en liste (seul l'état final d'un outil compte, comme au
|
||||
rejeu), la compaction signalée, un export partiel annoncé en tête ;
|
||||
- JSON : indenté et HTML échappé comme `json.MarshalIndent`, vue modèle sans
|
||||
mise à jour de contexte (`PROJ_SNAPSHOT`) ni image en base64 (élidée avec
|
||||
sa taille), journal filtré par les mêmes cases ;
|
||||
- les derniers échanges comptent à partir des messages de l'utilisateur, pas
|
||||
des rappels de Loki.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le reste du mode Code reste au Go et lit les mêmes clés : l'indice « ça
|
||||
ressemble à du code », les travaux de fond et l'outil `criteria` de l'agent.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- L'export part de la conversation vivante, pas de sa copie enregistrée :
|
||||
le dernier tour y est, même tout juste fini.
|
||||
- Les images d'un tour en cours restent dans l'export JSON, élidées. Une
|
||||
conversation relue par le Go les avait déjà perdues.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_chatx_test.go` tire du Go l'export d'une conversation
|
||||
riche sous 22 jeux d'options lus dans la requête. La conversation contient :
|
||||
- pièces jointes, raisonnement en deltas ;
|
||||
- outils finis, en cours, avec et sans sortie, labels piégés pour le
|
||||
Markdown ;
|
||||
- statistiques, compaction ;
|
||||
- images en base64 et en URL, rappel de Loki ;
|
||||
- mise à jour de contexte marquée, et une tapée par l'utilisateur.
|
||||
|
||||
Plus les exports d'une conversation vide.
|
||||
|
||||
Il tire aussi 23 appels aux routes du mode et des critères, avec la base et
|
||||
les événements du fil après chacun : erreurs JSON, y compris celles d'un
|
||||
critère mal typé (`Criterion.criteria.id`), et au-delà de vingt critères.
|
||||
|
||||
`tests/chatx.rs` rejoue l'export octet pour octet et les routes par la
|
||||
façade. Il vérifie aussi les en-têtes du téléchargement et le maintien des
|
||||
routes au Go sans la conversation. Une mutation de chaque règle fait échouer
|
||||
les tests.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Lot 20 — serveurs MCP tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le Go tenait les serveurs MCP (Model Context Protocol) :
|
||||
- `mcp_config.go` : la configuration, dans `state/mcp` ;
|
||||
- `mcp_catalog.go` : le catalogue embarqué ;
|
||||
- `mcp_client.go` : le pool de sessions, avec le SDK MCP Go ;
|
||||
- les routes `/api/mcp*` de `web_api.go`.
|
||||
|
||||
Le pool sert deux usages : l'état affiché dans l'interface, et les outils
|
||||
`mcp__<serveur>__<outil>` annoncés au modèle et exécutés pour l'agent.
|
||||
|
||||
Lot 20 : la façade tient tout cela quand `LOKI_MCP=rust` (`mcp/`,
|
||||
expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/mcp` | état de chaque serveur, connexion tentée pour les activés (en parallèle) |
|
||||
| `GET /api/mcp/catalog` | catalogue embarqué (le même fichier que le Go), `{workspace}` résolu, entrées déjà configurées marquées, lanceur (npx, uvx) présent ou non |
|
||||
| `POST /api/mcp/save` | ajoute ou remplace (actif par défaut) ; nom contrôlé, un seul transport |
|
||||
| `POST /api/mcp/delete`, `/api/mcp/toggle`, `/api/mcp/tool` | retire, active ou éteint, masque ou démasque un outil |
|
||||
| `POST /api/mcp/test` | reconnexion forcée |
|
||||
|
||||
## Client MCP en Rust
|
||||
|
||||
`mcp/client.rs` parle JSON-RPC 2.0 sur deux transports, comme le SDK Go :
|
||||
- stdio : un processus local, un message par ligne ;
|
||||
- Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE,
|
||||
session suivie par `Mcp-Session-Id`, en-têtes configurés ajoutés.
|
||||
|
||||
La session s'ouvre par `initialize` puis `notifications/initialized`. Elle
|
||||
sert `tools/list` (première page, comme le Go) et `tools/call`. Un `ping` du
|
||||
serveur reçoit sa réponse.
|
||||
|
||||
Le pool (`mcp/mod.rs`) suit `mcp_client.go` :
|
||||
- une session gardée d'un tour à l'autre ;
|
||||
- une entrée publiée avant la connexion, pour que deux appelants partagent
|
||||
la même tentative ;
|
||||
- une session invalidée pendant sa connexion n'est jamais republiée ;
|
||||
- délai de 3 min pour npx et uvx (premier téléchargement), 20 s sinon ;
|
||||
- un appel borné à 120 s, une reconnexion si la session est morte ;
|
||||
- la sortie plafonnée à la fenêtre de contexte (12 000 au moins), une erreur
|
||||
d'outil signalée, une sortie vide rendue « [ok] (aucune sortie) ».
|
||||
|
||||
## Le Go
|
||||
|
||||
Un seul pool, celui de la façade. Le Go, qui exécute encore les outils de
|
||||
l'agent, lui demande les outils à annoncer, leurs appels et le
|
||||
pré-chauffage (`/internal/facade/v1/mcp/{tools,call,prewarm}`,
|
||||
`facade_mcp.go`), quelle que soit la conversation qui tourne. Son propre
|
||||
pool reste vide.
|
||||
|
||||
L'image copie `internal/loki/mcp_catalog.json` dans l'étape de compilation
|
||||
Rust : un seul catalogue pour les deux binaires.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs de transport (connexion refusée, flux coupé) ont les messages
|
||||
du client Rust. Une erreur JSON-RPC et un binaire introuvable ont ceux du
|
||||
Go.
|
||||
- Un contenu « autre » (lien de ressource, ressource intégrée) est rendu en
|
||||
JSON dans l'ordre des champs du SDK. Les annotations éventuelles sont dans
|
||||
l'ordre alphabétique.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_mcp_test.go` tire du Go :
|
||||
- les fonctions pures : noms exposés, schémas, libellés d'appel, serveur
|
||||
d'origine d'un outil, ligne du préambule, délais et leurs messages,
|
||||
aplatissement de neuf résultats ;
|
||||
- la lecture de la configuration et seize mutations avec leurs erreurs et la
|
||||
base après chacune ;
|
||||
- ce qu'un vrai serveur du SDK MCP Go met sur le fil en Streamable HTTP :
|
||||
onze outils, et le résultat de chacun ;
|
||||
- ce que le client Go en tire : état de quatre serveurs (joignable,
|
||||
binaire introuvable, désactivé, mal configuré), outils annoncés (un
|
||||
masqué), appels.
|
||||
|
||||
`tests/mcp.rs` rejoue les fonctions et la configuration. Un faux serveur
|
||||
resert ce que le serveur du SDK a répondu (appels en SSE) : l'état, les
|
||||
outils et les appels tirés par la façade doivent être ceux du Go, sur une
|
||||
seule session. Sont aussi vérifiés de bout en bout :
|
||||
- le transport stdio : environnement, ping du serveur, serveur mort en plein
|
||||
appel puis reconnexion ;
|
||||
- les routes de configuration, en-têtes compris ;
|
||||
- le catalogue ;
|
||||
- une session invalidée pendant sa connexion, sans processus laissé
|
||||
derrière.
|
||||
|
||||
Une mutation de chaque règle fait échouer les tests. Côté Go,
|
||||
`TestFacadeMCP` vérifie le relais, les outils, les appels, le
|
||||
pré-chauffage et une façade injoignable.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Lot 21 — mise à jour de Loki tenue par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
`/api/update` (version disponible) et `/api/update/apply` (téléchargement et
|
||||
remplacement du binaire) vivaient dans `sys_update.go`. En conteneur
|
||||
(`LOKI_CONTAINER=1`), le Go n'y fait rien. L'image se met à jour par Docker,
|
||||
et les deux routes le disent (`containerUpdateNote`) :
|
||||
- `/api/update` : `{current, available: false, note}` ;
|
||||
- `/api/update/apply` : 409 `{ok: false, error: note}`.
|
||||
|
||||
## Le lot
|
||||
|
||||
La façade ne tourne qu'en conteneur. Elle tient ces deux routes quand
|
||||
`LOKI_UPDATE=rust` (`sysupdate.rs`, expérimental) et `LOKI_CONTAINER=1`.
|
||||
Sinon `loki-rs` ne passe pas la variable au Go, qui garde le téléchargement
|
||||
et le remplacement du binaire hors conteneur.
|
||||
|
||||
La version annoncée est celle du Go (`/internal/v1/auth`). Les binaires de
|
||||
la façade suivent toujours `loki-rs self-update`.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_update_test.go` tire du Go les quatre réponses en
|
||||
conteneur (GET et POST sur chaque route). `tests/sysupdate.rs` les rejoue par
|
||||
la façade, version du Go comprise.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Lot 22 — backends llama.cpp et job des moteurs tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le panneau Moteur s'appuie sur une famille de routes du Go :
|
||||
- `web_llamacpp.go` : statut, vérification git, bascule fast/opt, job ;
|
||||
- `backend_prebuilt.go` : binaires précompilés officiels ;
|
||||
- `web_api.go` : backends custom et leur désinstallation ;
|
||||
- `web_devices.go` : GPU vus par un moteur.
|
||||
|
||||
Elles partagent **un** job (`lcJob`) avec la mise à jour du moteur de l'image
|
||||
(`web_engine.go`) et l'installation de Strata (`backend_strata.go`). L'entête
|
||||
du job est en base (`state/build_job`), ses lignes dans
|
||||
`$LOKI_HOME/loki-build.log`. Au démarrage, un job noté « en cours » est déclaré
|
||||
interrompu (`lcRestore`).
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_BACKENDS=rust` (expérimental), la façade (`rust/loki-rs/src/backends`)
|
||||
sert les routes suivantes :
|
||||
|
||||
| Route | Rôle |
|
||||
| --- | --- |
|
||||
| `/api/llamacpp` | statut : dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build, conseil, job |
|
||||
| `/api/llamacpp/check` | `git fetch`, puis le retard sur origin |
|
||||
| `/api/llamacpp/prebuilt/check` | dernière release officielle comparée à la version installée |
|
||||
| `/api/llamacpp/prebuilt` | job : téléchargement, extraction (liens compris), VERSION, ménage, BIN, service arrêté puis relancé |
|
||||
| `/api/llamacpp/use` | bascule fast/opt, refusée pendant une optimisation |
|
||||
| `/api/llamacpp/job` | progression et lignes à partir d'un décalage |
|
||||
| `/api/llamacpp/uninstall-custom` | désinstallation d'un backend custom |
|
||||
| `/api/backends`, `/api/backends/custom` | les backends présents |
|
||||
| `/api/backends/devices` | GPU vus par un moteur, avec un cache mémoire et un cache disque |
|
||||
|
||||
**Le job vit dans la façade** (`backends/job.rs`) : même entête en base, même
|
||||
journal, même reprise au démarrage. Les jobs que le Go fait encore tourner lui
|
||||
rapportent chaque étape par l'API interne `/internal/facade/v1/build-job/*`
|
||||
(`start`, `line`, `phase`, `fail`, `done`, `commits`, `end`, `snapshot`).
|
||||
Ces jobs sont :
|
||||
- la compilation depuis les sources (`install`, `update`, `install-custom`,
|
||||
relayées au Go qui garde son refus 409 quand l'image fournit le moteur) ;
|
||||
- la mise à jour du moteur de l'image ;
|
||||
- Strata.
|
||||
|
||||
Côté Go (`facade_backends.go`), quand la façade tient le lot :
|
||||
- `startLcJob`, `lcAppend`, `lcPhase`, `lcFail`, `lcDone`, `lcSetCommit` et
|
||||
`lcJobSnapshot` passent par la façade ;
|
||||
- le garde de l'optimiseur reste au Go, avant le démarrage ;
|
||||
- le Go ne restaure plus de job au démarrage ;
|
||||
- les routes reçues par le tunnel sont relayées à la façade.
|
||||
|
||||
Pour arrêter et relancer le service, la façade pilote le moteur si elle le
|
||||
tient (lot 16), sinon elle le demande au Go (`/internal/v1/service/action`,
|
||||
avec `if_active`). Les sondes de la machine passent par un PATH et une racine
|
||||
injectables (`backends/probe.rs`) :
|
||||
- `nvidia-smi`, `nvcc`, `hipcc`, `/opt/rocm` ;
|
||||
- `glslc`, `vulkaninfo`, `ldconfig` et les chemins de `libvulkan`.
|
||||
|
||||
## Correction du Go
|
||||
|
||||
`prebuiltVersionFormat` lisait le format comme 3ᵉ champ du marqueur VERSION.
|
||||
Hors CUDA Windows, la version CUDA est vide : le marqueur
|
||||
« `b300 fmt2` » n'a alors que deux champs. Le format n'était donc jamais
|
||||
reconnu, et chaque vérification retéléchargeait la release sous Linux et
|
||||
macOS au lieu de dire « déjà à jour ». Le format est désormais le dernier
|
||||
champ, et la version CUDA ignore ce champ. Le port Rust suit.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_backends_test.go` (Linux) tire du Go un journal rejoué
|
||||
par `tests/backends.rs`. Le journal est sauté si `/usr/local/cuda*` ou
|
||||
`/opt/rocm` existent sur la machine.
|
||||
|
||||
Le journal contient :
|
||||
- **le statut** dans tous ses états :
|
||||
- dépôt git reproductible (dates et identité fixées), retard connu ou vérifié, tête détachée ;
|
||||
- origine perdue, BIN dans un fork ;
|
||||
- précompilé versionné, moteur téléchargé, moteur de l'image ;
|
||||
- **les backends custom** et chaque refus de désinstallation ;
|
||||
- **la bascule fast/opt** ;
|
||||
- **la dernière release** :
|
||||
- refus HTTP, tag vide, JSON illisible ;
|
||||
- aucun asset adapté ;
|
||||
- **le job précompilé** :
|
||||
- installation, « déjà à jour », ancien format réextrait ;
|
||||
- optimisation en cours, GitHub qui refuse ;
|
||||
- archive absente, archive piégée (`../`), archive sans serveur ;
|
||||
- **les GPU** :
|
||||
- liaison PCIe, mémoire complétée par nvidia-smi ;
|
||||
- cache, mémoire nulle non figée, repli sur le disque ;
|
||||
- moteur planté, tué ou non exécutable, pilote ancien, cartes de même nom ;
|
||||
- **après chaque appel** : BIN, entête en base, journal, `devices.json` et l'arborescence de `backends/` et `engine/` ;
|
||||
- **machine par machine** (dix fausses machines) : le plan de build, le conseil et la variante de release ;
|
||||
- **le job pas à pas** :
|
||||
- phases déduites des lignes de build, troncature à 4000 lignes, vues à partir d'un décalage ;
|
||||
- écritures en base limitées à une toutes les 2 s ;
|
||||
- refus d'un second job ;
|
||||
- reprise au démarrage dans tous les cas : absent, illisible, fini, interrompu, journal vide ou trop long.
|
||||
|
||||
Autres tests :
|
||||
- le Go (`TestFacadeBackends`) vérifie que ses jobs rapportent chaque étape à
|
||||
la façade, dans l'ordre, et que la compilation n'est pas relayée ;
|
||||
- la façade (`api_interne_du_job`, `service_arrete_puis_relance`) vérifie
|
||||
l'API interne et le service arrêté puis relancé autour de l'installation.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs réseau (GitHub injoignable) gardent le texte de `reqwest`.
|
||||
- Quand un asset de la release a un champ de mauvais type, le message n'est
|
||||
pas celui du Go.
|
||||
- `git fetch` lancé par `/api/llamacpp/check` pendant un job du Go n'écrit
|
||||
plus ses lignes dans ce job.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Lot 23 — sonde de gabarit portée en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
La sonde de gabarit (`chat_tplprobe.go`) dit ce que le gabarit de chat du
|
||||
modèle chargé fait de ce qu'on lui envoie :
|
||||
|
||||
- `preserves_history` : le raisonnement d'un tour passé est-il encore rendu ?
|
||||
- `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
|
||||
préfixe exact du suivant ?
|
||||
- `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
|
||||
|
||||
Le Go la lance après chaque complétion du fil principal, avec les outils et
|
||||
les `chat_template_kwargs` de la requête. Trois choses lisent son verdict :
|
||||
|
||||
- la politique d'écho du raisonnement (`REASONING_ECHO`) ;
|
||||
- le rappel de budget glissé dans le résultat d'outil (`NUDGE_IN_TOOL`) ;
|
||||
- le résumé des performances (`/api/perf/summary`).
|
||||
|
||||
Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
|
||||
servent le panneau Moteur, autour d'une mise à jour.
|
||||
|
||||
Quand la façade tenait la conversation (`LOKI_CHAT=rust`), plus aucune sonde
|
||||
ne tournait :
|
||||
|
||||
- l'écho décidait sans verdict ;
|
||||
- `NUDGE_IN_TOOL` gardait la conversation au Go (`facadeChatBlocker`).
|
||||
|
||||
## Le lot
|
||||
|
||||
`rust/loki-rs/src/chat/tplprobe.rs` porte la sonde entière :
|
||||
|
||||
- les requêtes au moteur local (`/health`, `/props`, `/apply-template`, avec la
|
||||
clé du moteur et les relances sur 503) ;
|
||||
- les rendus synthétiques et leurs verdicts ;
|
||||
- le cache par build, modèle, gabarit et forme ;
|
||||
- le dernier verdict, global et par forme ;
|
||||
- la ligne de journal ;
|
||||
- le déclenchement borné (une sonde en vol au plus, une par minute) ;
|
||||
- le relevé du rendu et sa comparaison.
|
||||
|
||||
Avec `LOKI_CHAT=rust` :
|
||||
|
||||
- **la boucle du tour** signale chaque complétion lue en entier sur le moteur
|
||||
local, outils ni coupés ni neutralisés (`TurnHost::completed`). L'hôte lance
|
||||
la sonde pour le fil principal seulement : ni tâche ni sous-agent ;
|
||||
- **la politique d'écho** lit le dernier verdict ;
|
||||
- **`NUDGE_IN_TOOL`** est porté (`nudge_in_tool_key`, `nudge_into_tool`,
|
||||
`TurnHost::prefix_unstable`). Il ne garde plus la conversation au Go ;
|
||||
- **le résumé des performances**, que sert encore le Go, relit le verdict de la
|
||||
façade (`/internal/facade/v1/chat/tpl`, `facadeTplCaps`).
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tplprobe_test.go` enregistre chaque échange de la sonde
|
||||
du Go avec un faux moteur : méthode, chemin, clé, corps envoyé, réponse.
|
||||
|
||||
Les comportements de moteur couverts :
|
||||
|
||||
- gabarit Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce
|
||||
ignorée ;
|
||||
- 404, exception du gabarit, clé exigée (inconnue ou connue) ;
|
||||
- 503 relancés ou persistants, 429 ;
|
||||
- `/props` illisible, moteur pas prêt, réponse sans prompt ;
|
||||
- rendus incohérents, preset externe.
|
||||
|
||||
Le journal contient aussi :
|
||||
|
||||
- le verdict, sa ligne de journal, `tplCapsCurrent` et `tplCapsFor` ;
|
||||
- le cache : même moteur, gabarit changé, build changé ;
|
||||
- le relevé du rendu sur trois gabarits, et une erreur ;
|
||||
- neuf comparaisons de relevés ;
|
||||
- l'empreinte de six formes.
|
||||
|
||||
`tests/tplprobe.rs` rejoue chaque échange contre le port. Le faux moteur rend
|
||||
les réponses enregistrées et exige des requêtes identiques, octet pour octet.
|
||||
Il vérifie aussi le déclenchement borné.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- `tests/lot6.rs` vérifie la sonde lancée après un vrai tour de la façade ;
|
||||
- les tests de la boucle vérifient le rappel dans le résultat d'outil et les
|
||||
complétions signalées ;
|
||||
- `TestFacadeSondeDeGabarit` vérifie, côté Go, que le résumé des performances
|
||||
relit le verdict de la façade.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Lot 24 — panneau « moteur de l'image » tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le panneau Moteur permet de mettre à jour llama.cpp sans reconstruire
|
||||
l'image. Le moteur est pris dans l'image de conteneur officielle
|
||||
(`ghcr.io/ggml-org/llama.cpp:server-cuda-b#####`), dont on ne télécharge que
|
||||
les couches du sommet, celles qui portent `/app`. Trois fichiers du Go le
|
||||
servent :
|
||||
|
||||
- `web_engine.go` : statut, dernier build publié, mise à jour, bascule,
|
||||
suppression ;
|
||||
- `web_engine_reco.go` : version recommandée, risque de rendu du
|
||||
raisonnement (défaut de b10763), retour à la version précédente,
|
||||
vérification du rendu du gabarit avant/après la bascule ;
|
||||
- `backend_engine_oci.go` : dialogue avec le registre, extraction des
|
||||
couches, essai à blanc.
|
||||
|
||||
La mise à jour est un job, le job des moteurs que la façade tient depuis le
|
||||
lot 22. Le risque de rendu et la vérification du rendu reposent sur la sonde
|
||||
de gabarit, portée au lot 23.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_ENGINE_IMAGE=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la
|
||||
façade (`rust/loki-rs/src/engineimg`) sert les routes suivantes :
|
||||
|
||||
| Route | Rôle |
|
||||
| --- | --- |
|
||||
| `/api/engine` | statut : binaire, build, source, variante, versions installées, avis, encart « version recommandée », version précédente, relevé du rendu |
|
||||
| `/api/engine/check` | dernier build publié pour la variante, comparé au courant |
|
||||
| `/api/engine/update` | job : recherche, téléchargement des couches du sommet, essai à blanc, REASONING_PRESERVE=off si demandé, bascule, ménage, vérification du rendu |
|
||||
| `/api/engine/use` | bascule vers une version installée ou vers le moteur de l'image |
|
||||
| `/api/engine/remove` | suppression d'une version installée (refusée pour celle qui tourne) |
|
||||
| `/api/engine/plan` | sur clic : version visée (dernière ou recommandée) et risque de rendu |
|
||||
| `/api/engine/rollback` | retour au moteur d'avant la dernière bascule |
|
||||
|
||||
Sans les backends, loki-rs ne passe pas la variable au Go : le panneau
|
||||
reste au Go et le dit au démarrage. `/api/engine/cmdline` reste au Go.
|
||||
|
||||
La sonde de gabarit est celle de la conversation quand la façade la tient
|
||||
(`LOKI_CHAT=rust`) : le relevé du rendu rejoue alors la forme de ses vraies
|
||||
requêtes. Sinon, la façade a sa propre sonde, qui sonde une requête nue.
|
||||
|
||||
Pour REASONING_PRESERVE=off posée dans le preset actif, la façade réutilise
|
||||
l'enregistrement du lot 15 (`save_applying`). Le service est arrêté puis
|
||||
relancé comme au lot 22 : par la façade si elle pilote le moteur, sinon par
|
||||
le Go (`/internal/v1/service/action`).
|
||||
|
||||
Côté Go, `facade_engineimg.go` relaie à la façade les routes reçues par le
|
||||
tunnel.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_engineimg_test.go` (Linux) tire du Go un journal
|
||||
rejoué par `tests/engineimg.rs`. Il tourne contre :
|
||||
|
||||
- un faux registre OCI : jeton, index multi-architecture, manifeste annoté,
|
||||
couches .tar.gz ;
|
||||
- un faux moteur local (le `fakeTpl` du Go, porté) pour la sonde et le rendu.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- **le statut** : moteur de l'image, téléchargé ou compilé, avec la version
|
||||
précédente (absente quand c'est le moteur courant), le relevé du rendu,
|
||||
sans moteur d'image, sans aucun moteur ;
|
||||
- **le dernier build** : publié, sans numéro, ou refusé (jeton refusé ou
|
||||
illisible, manifeste refusé ou illisible, aucune image pour
|
||||
l'architecture) ;
|
||||
- **le plan** :
|
||||
- dernière version (avec ou sans numéro) ou recommandée (build trop
|
||||
ancien, tag figé absent, sans numéro), et déjà à jour ;
|
||||
- chaque verdict du risque de rendu : moteur arrêté, gabarit vide,
|
||||
REASONING_PRESERVE posée, EXTRA_ARGS, gabarit sans les variables, gabarit
|
||||
qui garde la réflexion, build antérieur au défaut, preset externe ;
|
||||
- **la mise à jour** :
|
||||
- couches du sommet vers la base ; une couche du dessus masque celle du
|
||||
dessous ; liens absolus ramenés dans `/app` ; marqueurs de suppression et
|
||||
sous-dossiers ignorés ; couche sans fichier du moteur ; couche du runtime
|
||||
ignorée ; phase du téléchargement ;
|
||||
- essai à blanc ;
|
||||
- risque de rendu rappelé dans le journal, établi ou impossible à vérifier
|
||||
(moteur local arrêté) ;
|
||||
- REASONING_PRESERVE=off : posée dans le preset actif ou dans la
|
||||
configuration, déjà posée, ou inconnue du moteur ;
|
||||
- ménage : versions triées par build, dossier sans moteur épargné, version
|
||||
précédente gardée ;
|
||||
- rendu comparé avant/après : changé, identique, ou impossible sur le
|
||||
nouveau moteur ;
|
||||
- déjà à jour, corps illisible ;
|
||||
- **les échecs** :
|
||||
- optimisation en cours, version inconnue, image sans serveur ou sans
|
||||
version annoncée, manifeste sans couche, runtime au sommet, couche
|
||||
introuvable, tag illisible ;
|
||||
- moteur qui ne démarre pas (12 dernières lignes de sa sortie) ou qui ne
|
||||
voit aucune carte ;
|
||||
- **les bascules** : vers une version installée, vers l'image, vers une
|
||||
version absente, pendant une optimisation ; retour, deux fois, et en GET ;
|
||||
- **la suppression** : version (`../` neutralisé), version absente, moteur
|
||||
utilisé ;
|
||||
- **après chaque appel** : BIN, REASONING_PRESERVE, `engine/previous-bin` et
|
||||
l'arborescence de `engine/`.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- la façade vérifie le service arrêté puis relancé autour d'une mise à jour
|
||||
et redémarré par une bascule (`service_arrete_puis_relance`), le relevé
|
||||
« non vérifié à temps », le 503 sans backends, les variantes et les
|
||||
vérifications périmées ;
|
||||
- le Go (`TestFacadeMoteurImage`) vérifie que les sept routes partent à la
|
||||
façade, et elles seulement.
|
||||
|
||||
## Mutations
|
||||
|
||||
74 mutations du port Rust, toutes tuées sauf trois, équivalentes :
|
||||
|
||||
- la garde « moteur d'image absent » de la source : `same_path` ne
|
||||
rapproche jamais un chemin vide ;
|
||||
- l'essai à blanc qui relance l'inventaire des cartes quand l'ancien moteur
|
||||
est le nouveau : même résultat ;
|
||||
- une vérification du rendu périmée qui continue d'attendre son moteur : son
|
||||
relevé est de toute façon écarté au rangement.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs réseau (registre injoignable) gardent le texte de `reqwest`.
|
||||
- Un manifeste dont un champ a le mauvais type ne donne pas le message du
|
||||
Go : le nom Go du type anonyme n'est pas reproduit.
|
||||
- La vérification du rendu attend son délai à la cadence choisie (5 s), sans
|
||||
le contexte Go qui coupe aussi une requête en cours au bout de 20 min.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Lot 25 — Strata tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Strata est le deuxième moteur de Loki. Il répartit les experts d'un MoE de
|
||||
125 B (famille Qwen3.8-Flash-Next) entre VRAM, RAM et disque. Le moteur est
|
||||
le paquet **figé** publié par AJEAN : les sources et le binaire Linux CUDA 12,
|
||||
vérifiés par SHA-256. Un preset le choisit (`ENGINE=strata`).
|
||||
|
||||
`backend_strata.go` sert trois routes :
|
||||
|
||||
- `/api/strata` : la machine (cartes, RAM, disque, Python), ce qui y tient,
|
||||
la recommandation par version et les modèles installés, avec leur
|
||||
configuration de lancement en clair ;
|
||||
- `/api/strata/settings` : les réglages d'un modèle installé. S'il est en
|
||||
service, la configuration est réappliquée et le moteur relancé ;
|
||||
- `/api/strata/install` : un job des moteurs. Il télécharge et vérifie le
|
||||
paquet, lance l'installeur de Strata, écrit le preset et bascule dessus.
|
||||
|
||||
Le lancement (`loki serve` d'un preset Strata) vit aussi dans ce fichier.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_STRATA=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la façade
|
||||
(`rust/loki-rs/src/strata`) sert les trois routes :
|
||||
|
||||
- **la décision pure** (`strata/mod.rs`) :
|
||||
- placement des experts : drop, ram, arena ou disk, selon la RAM
|
||||
disponible, la carte d'appoint et `experts.bin` ;
|
||||
- configuration de lancement : réglages validés, carte d'aide, mode des
|
||||
experts, réglages du preset ;
|
||||
- ce qui tient sur la machine, la recommandation, les réglages, les noms ;
|
||||
- **la machine** (`strata/api.rs`) :
|
||||
- cartes via `nvidia-smi` (la sonde des backends) ;
|
||||
- Python 3.10+ et son module venv ;
|
||||
- RAM totale et disponible (`/proc/meminfo`) ;
|
||||
- RAM du moteur Strata en marche (arbre du service dans `/proc`) ;
|
||||
- place libre ;
|
||||
- **l'installation** : le job des moteurs. La sortie de l'installeur est
|
||||
découpée en lignes comme le `sinkWriter` du Go : une barre `\r` n'est
|
||||
montrée que toutes les 10 s. La bascule finale passe par le Go
|
||||
(`/internal/v1/preset/apply`, mode `switch`), qui applique le preset et
|
||||
relance le service.
|
||||
|
||||
Sans les backends, loki-rs ne passe pas la variable au Go et Strata reste au
|
||||
Go. Le lancement reste au Go (`loki serve`, que `loki-rs engine serve` lui
|
||||
délègue pour un preset Strata).
|
||||
|
||||
Côté Go, `facade_strata.go` relaie à la façade les routes reçues par le
|
||||
tunnel.
|
||||
|
||||
## Correction du Go
|
||||
|
||||
`/api/strata/settings` décidait si le preset était en service **après** y
|
||||
avoir écrit les nouveaux réglages. L'empreinte du nouveau contenu ne
|
||||
correspondait alors plus à la configuration active : changer un réglage du
|
||||
modèle en service ne l'appliquait jamais, et le moteur n'était pas relancé.
|
||||
« En service » se décide désormais avant l'écriture, comme dans
|
||||
`SavePresetApplying`. Le port Rust suit.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_strata_test.go` (Linux x86_64) tire du Go un journal
|
||||
rejoué par `tests/strata.rs`. Pour le tirer, le Go reçoit des crochets de
|
||||
test : `strataReleaseBase` (variable), `meminfoPath`, `strataDiskFree` et
|
||||
`serviceActionOSFn`. Le journal couvre :
|
||||
|
||||
- **la machine** :
|
||||
- sans carte, nvidia-smi en panne ou vide, carte trop ancienne ou trop
|
||||
petite, carte d'aide trop ancienne ;
|
||||
- trois cartes de même VRAM : la plus récente devant (capacité de calcul
|
||||
arrondie au dixième) ;
|
||||
- Python absent, en panne, illisible, trop ancien, sans venv ;
|
||||
- 16, 47 ou 64 Go de RAM, meminfo sans MemAvailable ;
|
||||
- disque presque plein ou juste trop petit (marge de 5 Go), données déjà
|
||||
présentes ;
|
||||
- **les réglages** :
|
||||
- GET, corps illisible, preset vide ou avec chemin, preset absent ou
|
||||
llama.cpp ;
|
||||
- hors limites, experts inconnus ;
|
||||
- preset inactif ; preset actif (appliqué et relancé), et pendant une
|
||||
optimisation ;
|
||||
- **l'installation** :
|
||||
- GET, corps illisible, quant inconnu, optimisation en cours ;
|
||||
- machine sans carte, modèle qui ne tient pas ;
|
||||
- release introuvable, moteur altéré (empreinte) ;
|
||||
- installation complète, puis réinstallation (sources déjà là et
|
||||
décompressées, moteur téléchargé périmé retéléchargé) ;
|
||||
- installeur en échec, installeur sans configuration ;
|
||||
- **les modèles installés** :
|
||||
- détails de lancement du modèle actif et de modèles inactifs (experts
|
||||
sur disque ou en RAM, vision coupée, sans carte d'aide) ;
|
||||
- mode du dernier lancement ;
|
||||
- `experts.bin` avec le moteur du paquet ou d'un autre paquet ;
|
||||
- RAM juste sous ou au-dessus du seuil du mode hors RAM, mode hors RAM
|
||||
coupé dans le preset ;
|
||||
- configuration de lancement disparue ou illisible ;
|
||||
- **après chaque appel** : les actions du service, les clés de la
|
||||
configuration et l'arborescence de `presets/` et `strata/` (sans le
|
||||
contenu des archives).
|
||||
|
||||
Autres tests :
|
||||
|
||||
- les tests du Go (`backend_strata_test.go`), portés (`strata/tests.rs`) :
|
||||
configuration de lancement, placement des experts, recommandation,
|
||||
réglages, noms ;
|
||||
- la RAM du moteur en marche, le découpage en lignes et les nombres écrits
|
||||
à la Go ;
|
||||
- le Go (`TestFacadeStrata`) : les trois routes partent à la façade, et
|
||||
elles seules.
|
||||
|
||||
## Mutations
|
||||
|
||||
71 mutations du port Rust, toutes tuées (les dix survivantes du premier
|
||||
passage l'ont été par les étapes et les tests ajoutés ci-dessus).
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs réseau (release injoignable) gardent le texte de `reqwest`.
|
||||
- Une copie du moteur qui échoue (`prebuilt/`) ne donne pas exactement le
|
||||
message du Go, qui ouvre la source puis la destination.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Lot 26 — Tâches planifiées tenues par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Une tâche planifiée tourne seule, sans utilisateur en face. Il en existe
|
||||
deux sortes :
|
||||
|
||||
- **une tâche IA** : une consigne, exécutée par l'agent dans un fil
|
||||
éphémère ;
|
||||
- **une tâche « script seul »** : un script du dossier protégé
|
||||
(`$LOKI_HOME/scripts`), lancé sans modèle. Sa sortie devient le
|
||||
compte-rendu.
|
||||
|
||||
Le Go les tient dans plusieurs fichiers :
|
||||
|
||||
- `tasks.go` : la base (`bkTasks`) et l'interrupteur maître ;
|
||||
- `tasks_cron.go` : les fréquences ;
|
||||
- `tasks_sched.go` : le planificateur, un tic par minute et une tâche due
|
||||
par tic ;
|
||||
- `tasks_run.go` et `tasks_script.go` : l'exécution ;
|
||||
- `web_tasks.go` : sept routes.
|
||||
|
||||
Les fréquences prennent quatre formes :
|
||||
|
||||
- `@every 2h` : intervalle glissant d'au moins une minute ;
|
||||
- `@every 3d@9:30` : tous les N jours, à une heure murale ;
|
||||
- `@once 2026-10-01 16:54` : une seule fois ;
|
||||
- une expression cron à cinq champs.
|
||||
|
||||
L'heure murale est celle du fuseau de la tâche. Sans fuseau, ou avec un
|
||||
fuseau inconnu, c'est l'heure locale du serveur.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_TASKS=rust` (expérimental), la façade (`rust/loki-rs/src/tasks`)
|
||||
sert les routes `/api/tasks*` et fait tourner le planificateur, que le Go ne
|
||||
démarre plus. Elle exécute elle-même les tâches script :
|
||||
|
||||
- le shell de l'agent (`run_shell`), dans le dossier de la discussion
|
||||
ouverte ;
|
||||
- une limite de 10 min ;
|
||||
- un arrêt par `/api/tasks/stop`.
|
||||
|
||||
Une tâche IA passe par le verrou de génération et les outils de l'agent, qui
|
||||
restent au Go. La façade la lui fait exécuter par `/internal/v1/tasks/run`,
|
||||
que le Go traite comme son propre planificateur l'aurait fait :
|
||||
|
||||
- preset épinglé ;
|
||||
- projet forcé ;
|
||||
- `RunAutonomous` ;
|
||||
- état persisté ;
|
||||
- notification.
|
||||
|
||||
Moteur occupé ou modèle pas prêt : la tâche ne tourne pas, son état ne bouge
|
||||
pas, et le tic suivant la réessaie. Le Go rend la main à la fin du passage.
|
||||
|
||||
Les notifications des tâches script partent par la façade si elle tient les
|
||||
notifications, sinon par le Go (`/internal/v1/push/send`).
|
||||
|
||||
Les fréquences demandent l'heure murale d'un fuseau IANA. `src/tz.rs` porte
|
||||
ce que le Go fait de la zoneinfo du système :
|
||||
|
||||
- lecture des TZif v1 et v2, et de la règle POSIX qui prolonge les
|
||||
transitions ;
|
||||
- `time.Local` (`$TZ`, `/etc/localtime`) ;
|
||||
- `time.Date`, avec la normalisation des débordements ;
|
||||
- les heures qui sautent ou qui se répètent, résolues comme le Go.
|
||||
|
||||
`tasks/cron.rs` porte `time.ParseDuration` du Go, messages d'erreur compris.
|
||||
|
||||
Côté Go :
|
||||
|
||||
- `facade_tasks.go` : le relais des routes reçues par le tunnel et les deux
|
||||
routes internes ;
|
||||
- `web_server.go` : le planificateur n'est pas démarré ;
|
||||
- `tasksNow` : l'heure des tâches, remplaçable en test.
|
||||
|
||||
## Correction de la façade
|
||||
|
||||
Les appels de la façade à l'API interne du Go étaient tous bornés à 10 s. Un
|
||||
outil exécuté par le Go (`/internal/v1/tool/exec`) qui dure plus longtemps
|
||||
était coupé :
|
||||
|
||||
- une page web lente ;
|
||||
- le navigateur ;
|
||||
- un serveur MCP tenu par le Go.
|
||||
|
||||
Le résultat partait en erreur alors que l'outil continuait. Ces appels sont
|
||||
désormais bornés à 30 min. Une tâche IA exécutée par le Go est attendue sans
|
||||
autre limite que 24 h.
|
||||
|
||||
## Ce que les mutations ont corrigé dans le port
|
||||
|
||||
Le premier passage des mutations a laissé survivre des cas que le journal ne
|
||||
voyait pas. Confrontés au Go, plusieurs étaient de vrais écarts :
|
||||
|
||||
- **dates lointaines** : un instant en nanosecondes sur 64 bits s'arrête en
|
||||
2262. Le Go accepte `@once 2400-02-29 10:00`, ou un intervalle en jours
|
||||
qui mène au-delà. Les instants des fréquences passent sur 128 bits ;
|
||||
- **intervalle en jours démesuré** : `@every 200000d` déborde dans le Go
|
||||
(`time.Duration`), qui rend une date passée. Le port déborde de même ;
|
||||
- **l'heure qui saute** : `@every 1d@02:30` à Paris, le jour du passage à
|
||||
l'heure d'été, tombe à 3 h 30. Le Go avance ensuite de jour en jour depuis
|
||||
ce candidat (`AddDate`) et reste à 3 h 30. Le port repartait de 2 h 30 ;
|
||||
- **compte-rendu tronqué** : le Go coupe à 4000 octets, au milieu d'un
|
||||
caractère s'il le faut, et chaque octet orphelin devient U+FFFD à
|
||||
l'encodage JSON. Le port reculait à une frontière de caractère ;
|
||||
- **nom de fuseau** : le Go refuse tout nom qui contient `..`, pas seulement
|
||||
un composant `..`.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tasks_test.go` (Linux) tire du Go un journal rejoué
|
||||
par `tests/tasks.rs`. L'heure des tâches est fixée pas à pas ; les
|
||||
identifiants tirés au hasard deviennent `{{ID1}}`, `{{ID2}}`… dans l'ordre où
|
||||
ils apparaissent. Le journal couvre :
|
||||
|
||||
- **l'écran** :
|
||||
- scripts du dossier (sous-dossiers compris) ;
|
||||
- mode agent, interrupteur maître (rangé à « 0 » : pas suspendu) ;
|
||||
- projets, mémoire coupée, internet armé ;
|
||||
- la tâche en cours pendant un script ;
|
||||
- **l'enregistrement** :
|
||||
- corps illisible, sans nom, sans consigne ;
|
||||
- script absent, dossier, remontée, chemin absolu, séparateur Windows,
|
||||
caractère interdit ;
|
||||
- chaque erreur de fréquence : vide, intervalle trop court, illisible ou
|
||||
sans unité, jours ou heure invalides, cron à 4 champs, hors bornes, en
|
||||
lettres, plage inversée, pas nul, jamais satisfait, date illisible ou
|
||||
déjà passée ;
|
||||
- chaque forme de fréquence, dans plusieurs fuseaux : Paris (dont l'heure
|
||||
qui saute), New York, Calcutta, fuseau inconnu ;
|
||||
- id inconnu gardé, projet inconnu remplacé par l'actif ;
|
||||
- modification avec ou sans changement de fréquence ;
|
||||
- **la bascule, l'interrupteur maître, la suppression** ;
|
||||
- **les lancements** :
|
||||
- tâche absente ;
|
||||
- tâche IA avec moteur occupé (409) ou modèle pas prêt (503) ;
|
||||
- script en succès, en échec, arrêté à la main ;
|
||||
- script qui échoue après un succès, puis se rétablit ;
|
||||
- tâche script rangée sans script, au nom refusé, au script disparu ;
|
||||
- **le planificateur** :
|
||||
- tic suspendu, tic pendant une optimisation ;
|
||||
- une tâche IA due qui attend le modèle ;
|
||||
- une tâche due par tic, dans l'ordre des noms ;
|
||||
- la tâche « une fois » qui s'éteint ;
|
||||
- une tâche sans prochain passage (posé sans la lancer) ;
|
||||
- une fréquence devenue invalide ;
|
||||
- deux tâches de même nom (rangées par id).
|
||||
|
||||
Après chaque étape, le journal enregistre les tâches en base.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- fuseaux et fréquences confrontés au Go :
|
||||
- Paris aux deux changements d'heure, New York, Calcutta ;
|
||||
- avant la première transition, pile à une transition, la table avant la
|
||||
règle prolongée ;
|
||||
- `$TZ` (vide, « : », chemin, nom), `/etc/localtime` ;
|
||||
- noms refusés, « UTC » sans base ;
|
||||
- mois hors bornes, années bissextiles, mois de 30 jours ;
|
||||
- `ParseDuration` et ses débordements, cron (plages, pas, bornes, à 78 et
|
||||
364 jours) ;
|
||||
- le compte-rendu tronqué, les noms de script ;
|
||||
- l'amorçage au démarrage (tâches actives sans prochain passage) ;
|
||||
- la durée d'un passage, à une horloge qui avance ;
|
||||
- la tâche IA en cours vue par l'écran ;
|
||||
- la tâche IA lancée à la main part au Go (`tache_ia_executee_par_le_go`) ;
|
||||
- le planificateur tourne à son pas sans demande ;
|
||||
- sans `LOKI_TASKS`, les routes partent au Go ;
|
||||
- le Go (`TestFacadeTaches`) :
|
||||
- les sept routes partent à la façade, et elles seules ;
|
||||
- `/internal/v1/tasks/run` sur une tâche IA sans modèle (occupée, rien
|
||||
d'enregistré) et sur une tâche script ;
|
||||
- `/internal/v1/push/send`.
|
||||
|
||||
## Mutations
|
||||
|
||||
79 mutations du port Rust, dont 77 tuées. Les survivantes du premier passage
|
||||
l'ont été par les tests et les étapes ajoutés ci-dessus. Restent deux
|
||||
équivalentes :
|
||||
|
||||
- le refus d'un nom de script absolu **après** nettoyage : sous Unix, un nom
|
||||
qui ne commence pas par `/` ne le devient pas (le contrôle du Go vaut pour
|
||||
Windows) ;
|
||||
- `web_on` sans `internet_enabled` : la joignabilité refuse déjà un moteur
|
||||
Crawl4AI sans adresse, seul cas où les deux diffèrent.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Pendant une tâche IA, le Go force le projet de la tâche. Une tâche
|
||||
enregistrée sans projet à ce moment-là prend, côté façade, le projet actif
|
||||
rangé.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Lot 27 — Banc d'essai tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le banc d'essai mesure le moteur local tel que le chat s'en sert, sur
|
||||
`/v1/chat/completions`, avec le gabarit, le raisonnement et
|
||||
l'échantillonnage du preset (`llm_bench.go`, `llm_bench_job.go`). Il a deux
|
||||
modes :
|
||||
|
||||
- **rapide** : un préchauffage jeté, puis la ligne courte (~2000 jetons de
|
||||
prompt, ~300 générés) ;
|
||||
- **complet** : la ligne courte, puis une discussion à profondeur réelle —
|
||||
un prefill à froid de D jetons, et trois tours qui y ajoutent chacun ~2048
|
||||
jetons neufs. La reprise du cache est lue dans la réponse, jamais
|
||||
supposée.
|
||||
|
||||
Il tourne en tâche de fond derrière quatre routes :
|
||||
|
||||
- `POST /api/bench` lance (202 et un id) ;
|
||||
- `GET /api/bench/status` dit la phase, puis le résultat ou l'erreur ;
|
||||
- `POST /api/bench/cancel` annule ;
|
||||
- `GET /api/bench/last` rend la dernière mesure enregistrée.
|
||||
|
||||
Pendant la mesure, le bench tient le verrou de génération de la
|
||||
conversation. Un message, une tâche planifiée ou un client `/v1` reçoivent
|
||||
le refus du bench. Seule une mesure complète est enregistrée : la
|
||||
dernière, et celle du preset actif.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_BENCH=rust` (expérimental), la façade (`rust/loki-rs/src/bench`)
|
||||
sert les quatre routes et déroule le protocole contre le moteur :
|
||||
|
||||
- `bench/mod.rs` : le protocole, porté du Go à l'octet près. On y trouve :
|
||||
- les mêmes corpus : la prose du Go et l'interface web embarquée, de la
|
||||
même source ;
|
||||
- les mêmes requêtes ;
|
||||
- le même calcul de profondeur, de reprise du cache et de débit ;
|
||||
- les mêmes messages d'erreur ;
|
||||
- `bench/job.rs` : le job, les routes et l'enregistrement (`last_bench`,
|
||||
`bench_presets`, avec l'empreinte de configuration du Go).
|
||||
|
||||
Le verrou de génération reste celui de la conversation du Go. La façade le
|
||||
prend par le **bail du moteur**, avec le motif `bench` :
|
||||
|
||||
- le Go note alors `benching` et donne aux autres le refus du benchmark ;
|
||||
- changer de discussion ne rompt pas le bail ;
|
||||
- `benchRunning()` le voit aussi : les clients `/v1`, le préchauffage et
|
||||
l'optimiseur attendent ;
|
||||
- un stop du chat côté Go (signalé au renouvellement), un Reset ou un bail
|
||||
perdu annulent le bench.
|
||||
|
||||
Le Go prête aussi à la façade :
|
||||
|
||||
- **le travail annexe** (`/internal/v1/engine/sidejob`) : à la fin, le slot
|
||||
du bench est effacé si c'est sans risque, comme pour un bench du Go ;
|
||||
- **la télémétrie** (`/internal/v1/bench/perf`) : la trace du bench dans
|
||||
`perf_log`.
|
||||
|
||||
Le port, la clé de l'API locale et le second slot (`SIDE_SLOT`) viennent de
|
||||
`/internal/v1/oai/route`. Les niveaux de raisonnement appris sont ceux de la
|
||||
conversation de la façade si elle la tient, sinon ceux du bench.
|
||||
|
||||
La ligne de commande (`loki bench`) reste au Go.
|
||||
|
||||
## Correction des tests du Go
|
||||
|
||||
Trois tests du préchauffage échouaient dix minutes par jour, avant minuit :
|
||||
le préchauffage se tait alors (`prewarmNearMidnight`), et ils lisaient
|
||||
l'heure réelle. L'heure de ce garde-fou devient remplaçable (`prewarmNow`),
|
||||
et les tests la fixent à midi.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_bench_test.go` (Linux) tire du Go un journal rejoué
|
||||
par `tests/bench.rs` contre le même faux moteur (`fakeBenchEngine`, porté).
|
||||
Chaque requête reçue par le moteur est comparée ; les longs textes (corpus)
|
||||
sont réduits à leur taille et à leur empreinte. Le journal couvre :
|
||||
|
||||
- **les routes** : statut et dernière mesure sans bench, GET refusés,
|
||||
annulation sans bench ;
|
||||
- **les refus au lancement** : preset externe, optimisation en cours, modèle
|
||||
pas prêt, génération en cours, second lancement pendant un bench ;
|
||||
- **les échecs dans le job** : moteur occupé par un autre (`/slots`), preset
|
||||
Strata ;
|
||||
- **les mesures** :
|
||||
- mode rapide, puis mode complet (profondeur, trois tours, reprise du
|
||||
cache, brouillon) ;
|
||||
- contexte trop petit (profondeur sautée), contexte inconnu du moteur
|
||||
(celui du preset) ;
|
||||
- paramètres bornés, petits paramètres, corps illisible ;
|
||||
- **les échecs d'une mesure** :
|
||||
- préchauffage, ligne courte, prefill à froid ou tour en profondeur en
|
||||
erreur ;
|
||||
- réponse sans timings ;
|
||||
- **le raisonnement** : niveau refusé par le gabarit puis rejoué, puis le
|
||||
niveau appris envoyé d'emblée ;
|
||||
- **budget et annulation** : budget d'une requête dépassé (profondeur
|
||||
partielle, non enregistrée), annulation pendant la profondeur ;
|
||||
- **après chaque étape** : la dernière mesure et les mesures par preset.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- ceux du Go (`llm_bench_test.go`), portés (`bench/tests.rs`) : profondeur,
|
||||
types de cache, requête, morceaux du corpus, reprise du cache, relectures
|
||||
du disque, empreinte de l'enregistrement ;
|
||||
- la réponse du moteur (timings, cache, brouillon, nombres illisibles),
|
||||
`Duration.String` du Go, les flottants écrits à la Go ;
|
||||
- la façade : le bail « bench », le travail annexe (slot servi ou non), la
|
||||
trace, la clé de l'API locale, le second slot, le stop du chat et le bail
|
||||
perdu, les routes sans la façade ;
|
||||
- le Go (`TestFacadeBench`) :
|
||||
- les quatre routes partent à la façade, et elles seules ;
|
||||
- le bail « bench » : refus, bascule de discussion, libération,
|
||||
expiration ;
|
||||
- le travail annexe et la trace.
|
||||
|
||||
## Mutations
|
||||
|
||||
84 mutations du port Rust, dont 81 tuées. Les survivantes du premier passage
|
||||
l'ont été par les tests ajoutés :
|
||||
|
||||
- `bench/tests.rs` : la tranche pile à la fin du corpus, un tour sans
|
||||
attendu à côté d'un tour mesuré, les débits nuls rendus par le moteur
|
||||
(recalculés), le niveau de raisonnement rejoué une seule fois, le
|
||||
préchauffage et la ligne courte d'un `run` rapide (paramètres par défaut,
|
||||
64 jetons de préchauffage au moins, code ajouté juste au-delà de la
|
||||
prose), la profondeur arrêtée (contexte plein, corpus épuisé, réponse du
|
||||
tour précédent reprise, note de reprise selon le modèle hybride) ;
|
||||
- `tests/bench.rs` : un second lancement pendant la prise du bail, le preset
|
||||
relu au départ du job (devenu externe, ou optimiseur lancé entre-temps),
|
||||
la mesure enregistrée sous le preset ACTIF ;
|
||||
- le journal : un prompt juste au-delà de la prose.
|
||||
|
||||
Restent trois équivalentes :
|
||||
|
||||
- l'annulation vérifiée avant d'envoyer : déjà annulé, le `select!` rend
|
||||
l'annulation à son premier tour, avant que la requête ne parte ;
|
||||
- la progression d'un job remplacé : un seul bench à la fois, un job fini ne
|
||||
rapporte plus rien ;
|
||||
- le PID nul du moteur : `/proc/0` n'existe pas, la lecture échoue de toute
|
||||
façon.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Une erreur réseau vers le moteur garde le texte de `reqwest` après le
|
||||
préfixe du Go (`Post "url": …`).
|
||||
- Un niveau de raisonnement appris par le bench de la façade n'est pas
|
||||
transmis au chat du Go, quand le Go tient la conversation (et
|
||||
inversement) : la première requête paie le refus, puis la traduction est
|
||||
retenue.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Lot 8 — routes des modèles tenues par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les modèles `.gguf` vivaient dans le Go :
|
||||
|
||||
- la liste du sélecteur (`handleModels`, `backend_models_refs.go`) ;
|
||||
- les dossiers de modèles (`backend_model_dirs.go`) ;
|
||||
- la suppression (`deleteModelFile`) ;
|
||||
- les téléchargements (`backend_models.go`, `backend_models_shards.go`) ;
|
||||
- le catalogue Hugging Face (`backend_hf.go`, `web_hf.go`, `backend_catalog.go`).
|
||||
|
||||
Lot 8 : la façade tient tout cela quand `LOKI_MODELS=rust` (expérimental ;
|
||||
sans la variable rien ne change, tout part au Go).
|
||||
|
||||
## Ce que sert la façade
|
||||
|
||||
| Route | Module | Rôle |
|
||||
|---|---|---|
|
||||
| `GET /api/models` | `models.rs` | `.gguf` des dossiers déclarés (tranches 2..N exclues), taille de la famille, tranches manquantes, qui les référence (presets, configuration en service) |
|
||||
| `POST /api/models/delete` | `models.rs` | refus si en service ; 409 avec la liste des presets sans `force` ; le modèle part avec toutes ses tranches, octets libérés rendus |
|
||||
| `GET\|POST /api/models/dirs` | `models.rs` | dossiers de modèles, nombre de modèles, espace libre ; ajout (dossier existant) et retrait |
|
||||
| `POST /api/models/download/probe` | `downloads.rs` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` |
|
||||
| `POST /api/models/download` | `downloads.rs` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes |
|
||||
| `GET /api/models/download/status` | `downloads.rs` | état de chaque téléchargement de ce lancement |
|
||||
| `POST /api/models/download/cancel` | `downloads.rs` | annule ; ce que le téléchargement a écrit est effacé |
|
||||
| `GET /api/hf/search` | `hf.rs` | dépôts GGUF, matériel local joint |
|
||||
| `GET /api/hf/files` | `hf.rs` | modèles, projecteurs et poids MTP d'un dépôt, verdict mémoire de chaque modèle (contexte du preset actif, projecteur du dépôt) |
|
||||
| `GET\|POST /api/hf/token` | `hf.rs` | jeton vérifié auprès de Hugging Face avant d'être enregistré, jamais rendu en clair |
|
||||
|
||||
Mêmes contrats (corps, codes, messages) et même mécanique que le Go, entre
|
||||
autres :
|
||||
|
||||
- téléchargement :
|
||||
- lien de page Hugging Face (`/blob/`) converti en lien direct ;
|
||||
- modèle en tranches rapatrié en entier ;
|
||||
- place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de
|
||||
refus sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS ;
|
||||
- plages d'octets en parallèle (`LOKI_DL_CONNS`) et reprise d'une
|
||||
connexion coupée ;
|
||||
- jeton envoyé aux seuls hôtes Hugging Face ;
|
||||
- catalogue : réponses mises en cache (60 s pour une recherche, 10 min pour un
|
||||
dépôt), cache vidé à l'enregistrement d'un jeton ;
|
||||
- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait
|
||||
plus, il effacerait un téléchargement qu'elle mène.
|
||||
|
||||
## Côté Go (`facade_models.go`)
|
||||
|
||||
Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par
|
||||
la façade : `withFacadeChat` lui remet ces routes, authentifiées par le
|
||||
jeton interne, comme celles du chat. Façade injoignable : 502 « modèles
|
||||
indisponibles : … ».
|
||||
|
||||
La façade transmet `LOKI_MODELS=rust` et son adresse au Go qu'elle lance.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Une source qui ignore les plages n'est plus lue en entier pendant la sonde.
|
||||
- Erreurs de transport (réseau, TLS) au format de reqwest.
|
||||
- Message d'un corps JSON illisible : celui de serde (l'interface n'en envoie
|
||||
jamais).
|
||||
- Hôte d'un lien collé en majuscules converti comme les autres.
|
||||
- Espace libre et mémoire vive inconnus hors Unix/Linux (la façade n'est
|
||||
livrée que pour Linux).
|
||||
- `/api/models/download/status` trié par date de début.
|
||||
- Nombres flottants du matériel écrits `24.0` (le Go écrit `24`) : même
|
||||
valeur JSON.
|
||||
|
||||
## Parité
|
||||
|
||||
Journaux tirés du Go et rejoués en Rust (`LOKI_PARITY_UPDATE=1` pour
|
||||
régénérer) :
|
||||
|
||||
- `internal/loki/parity_dl_test.go` → `tests/parity_models/download.json` →
|
||||
`tests/downloads.rs` : lien normalisé, famille de tranches,
|
||||
`Content-Range`, messages de refus, hôtes et dépôts Hugging Face, dossier
|
||||
de destination ;
|
||||
- `internal/loki/parity_models_test.go` → `tests/parity_models/models.json` →
|
||||
`tests/models.rs` : quantification déduite du nom, découpage
|
||||
d'`EXTRA_ARGS`, jeton masqué, `gated`, classement d'un dépôt, choix du
|
||||
projecteur, verdicts mémoire, puis un scénario sur disque de 20 appels
|
||||
(liste, dossiers, suppressions avec et sans `force`, modèle en service,
|
||||
tranches, chemins refusés) rejoué à l'identique, fichiers restants et
|
||||
dossiers enregistrés compris.
|
||||
|
||||
Deux mutations (`replacen` → `replace` sur `/blob/`, `--mmproj` ignoré)
|
||||
font échouer les rejeux.
|
||||
|
||||
De bout en bout :
|
||||
|
||||
- téléchargements contre un faux Hugging Face : trois connexions sur des
|
||||
plages disjointes, source sans plages, tranches dont une présente, dépôt
|
||||
verrouillé, panne pendant la deuxième tranche, annulation et 409 ;
|
||||
- catalogue contre un faux Hugging Face : recherche, cache, taille LFS,
|
||||
dépôt verrouillé ou absent, jeton refusé ou accepté ;
|
||||
- toutes les routes par la façade (clé de pilotage, remise du Go, base du Go
|
||||
écrite), et laissées au Go sans la variable.
|
||||
|
||||
## Reste
|
||||
|
||||
- Essai réel sur une installation avant d'en faire le défaut.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Lot 9 — dictée vocale tenue par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La dictée vocale vivait dans le Go :
|
||||
|
||||
- `dictate_config.go` : réglages ;
|
||||
- `dictate_models.go` : catalogue Parakeet, téléchargement et extraction ;
|
||||
- `dictate_server.go` : supervision de `sherpa-onnx-offline-websocket-server`
|
||||
et protocole WebSocket ;
|
||||
- `web_transcribe.go` : routes.
|
||||
|
||||
Lot 9 : la façade la tient quand `LOKI_DICTATE=rust` (expérimental ; sans la
|
||||
variable rien ne change).
|
||||
|
||||
## Ce que sert la façade (`rust/loki-rs/src/dictate.rs`)
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `POST /api/transcribe` | un WAV (≥ 1000 octets, ≤ 32 Mio) entre, le texte sort ; modèle absent : 503 `{downloading, pct}` et téléchargement lancé (relancé après un échec, qui est dit) |
|
||||
| `GET\|POST /api/dictate/config` | modèle (validé contre le catalogue) et réactivité ; enregistrer arrête le serveur, qui repart au prochain mot avec le nouveau modèle |
|
||||
| `GET /api/dictate/models` | catalogue trié par taille, présence des quatre fichiers |
|
||||
| `GET\|POST /api/dictate/models/download` | un téléchargement à la fois, progression sur les octets reçus |
|
||||
| `GET /api/dictate/state` | serveur allumé (et depuis quand), modèle, présence, téléchargement |
|
||||
| `POST /internal/facade/v1/dictate/stop` | (le Go, jeton interne) arrêt sans attendre derrière un démarrage |
|
||||
|
||||
Même mécanique que le Go :
|
||||
|
||||
**Archive `.tar.bz2`**
|
||||
- Décompressée au fil du téléchargement, dans un dossier `.part` renommé à la fin.
|
||||
- Entrées absolues ou qui remontent refusées, liens ignorés.
|
||||
|
||||
**Serveur de transcription**
|
||||
- Lancé à la demande sur un port libre de la boucle locale, GPU masqués, sans
|
||||
le jeton interne dans son environnement.
|
||||
- Prêt dès que sa socket répond.
|
||||
- Une mort au démarrage est rapportée avec sa cause et la dernière ligne du
|
||||
journal ; un processus qui ne répond pas fait échouer le démarrage au bout
|
||||
de 2 minutes.
|
||||
- Éteint après dix minutes d'inactivité.
|
||||
- `shutdown_fast` tue le processus qui démarre au lieu d'attendre le verrou.
|
||||
|
||||
**Protocole**
|
||||
- En-tête de 8 octets (fréquence, nombre d'octets), échantillons float32 en
|
||||
trames de 10 Kio, une trame texte en retour.
|
||||
- Annotations (`[musique]`, `(rires)`) écartées.
|
||||
|
||||
## Côté Go (`facade_dictate.go`)
|
||||
|
||||
- `withFacadeChat` remet les routes du tunnel et de la redistribution E2E à la
|
||||
façade. Si elle ne répond pas : 502 « dictée indisponible : … ».
|
||||
- `asrShutdownVite` passe par la façade quand elle tient la dictée : son
|
||||
serveur n'est pas un enfant de ce process. Cela couvre la libération de la
|
||||
VRAM et l'arrêt du moteur principal par l'optimiseur.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Messages d'erreur de transport (WebSocket, HTTP) et de décompression au
|
||||
format des crates Rust.
|
||||
- Fin de connexion sans réponse : « lecture du résultat : connexion fermée ».
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_dictate_test.go` tire du Go ce journal :
|
||||
|
||||
- treize WAV : mono, bloc `LIST` intercalé, stéréo moyenné, flottant 32 bits,
|
||||
données tronquées, impaires, vides, et les refus ;
|
||||
- les chemins d'archive ;
|
||||
- les marqueurs ;
|
||||
- les réglages par défaut ;
|
||||
- les arguments du serveur ;
|
||||
- les adresses des modèles ;
|
||||
- la présence ;
|
||||
- treize appels de route.
|
||||
|
||||
Le tout est rangé dans `tests/parity_dictate/dictate.json` et rejoué par
|
||||
`tests/dictate.rs`, les routes par la façade. Une mutation (premier canal au
|
||||
lieu de la moyenne) fait échouer le rejeu.
|
||||
|
||||
`tests/dictate.rs` couvre aussi :
|
||||
|
||||
- le protocole contre un faux serveur WebSocket (en-tête, découpage
|
||||
10240 + 10240 + 3528, marqueurs, réponse illisible, serveur injoignable) ;
|
||||
- l'extraction (lien ignoré, entrée qui remonte refusée sans rien laisser,
|
||||
archive corrompue) ;
|
||||
- le téléchargement depuis un faux serveur (100 %, HTTP 404, modèle inconnu,
|
||||
un seul à la fois) ;
|
||||
- la supervision d'un faux sherpa-onnx (script Python qui parle le
|
||||
protocole) :
|
||||
- transcription de bout en bout par la façade ;
|
||||
- même serveur réutilisé ;
|
||||
- arrêt demandé par le Go, et refusé sans le jeton ;
|
||||
- réglage changé ;
|
||||
- les démarrages ratés : sortie en code 3 avec sa dernière ligne, serveur
|
||||
muet, arrêt « vite » qui tue le démarrage, binaire introuvable, modèle
|
||||
absent.
|
||||
@@ -0,0 +1,263 @@
|
||||
# Lot 28 — Optimiseur tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
L'optimiseur sans perte (`backend_tune*.go`) cherche des réglages du moteur
|
||||
plus rapides **sans rien changer à ce que le modèle calcule** : même
|
||||
contexte, mêmes slots, mêmes types de cache, même échantillonnage. Il
|
||||
procède ainsi :
|
||||
|
||||
1. Il arrête le vrai moteur.
|
||||
2. Il lance chaque essai sur une COPIE de la configuration :
|
||||
- posée dans `LOKI_HOME/tune/run/<essai>/config.env`, jamais
|
||||
`config.env` ;
|
||||
- composée par « loki serve » à blanc ;
|
||||
- puis lancée sur 127.0.0.1 et un port libre.
|
||||
3. Il mesure chaque essai par le bench complet, à une profondeur fixe.
|
||||
4. Il garde ce qui gagne au-delà du bruit.
|
||||
5. Il relance le vrai moteur.
|
||||
|
||||
Rien n'est écrit dans un preset sans un geste de l'utilisateur : dans une
|
||||
copie, ou dans le preset lui-même — moteur redémarré, sondé, et ancienne
|
||||
version rétablie si la sonde échoue.
|
||||
|
||||
Cinq routes :
|
||||
|
||||
- `POST /api/tune` lance (202) ;
|
||||
- `GET /api/tune/status` dit la phase, les essais, l'ETA, puis le résultat ;
|
||||
- `POST /api/tune/cancel` annule ;
|
||||
- `POST /api/tune/apply` écrit le résultat, sur un clic (`copy` ou
|
||||
`preset`) ;
|
||||
- `GET /api/tune/last` rend le dernier résultat d'un preset, et si le preset
|
||||
ou le moteur ont changé depuis.
|
||||
|
||||
Le moteur, pendant une optimisation, appartient aux essais. Le verrou est un
|
||||
**fichier** de `LOKI_HOME` (`tune.lock`), créé de façon exclusive, que tout
|
||||
le monde lit :
|
||||
|
||||
- le Go, dans tous ses refus (`tuneGuard`) ;
|
||||
- « loki tune » en ligne de commande ;
|
||||
- désormais la façade.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_TUNE=rust` (expérimental, Unix seulement), la façade
|
||||
(`rust/loki-rs/src/tune`) sert les cinq routes et déroule l'optimisation :
|
||||
|
||||
- `tune/core.rs` : la partie pure, portée à l'octet près :
|
||||
- découpe et réécriture d'`EXTRA_ARGS` ;
|
||||
- réglages par clé ou par drapeau ;
|
||||
- essais de chaque étape selon la machine et les cases cochées ;
|
||||
- garde-fous : cœur de la ligne, dénature, clés permises ;
|
||||
- diff et réécriture du preset ;
|
||||
- journal d'un essai ;
|
||||
- score et départage ;
|
||||
- `tune/lock.rs` : le verrou, **au format du Go** :
|
||||
- contenu du fichier, clé de prise, binaires comparés par leur nom ;
|
||||
- identité d'un processus comme `procIdentity` l'écrit : champ 22 de
|
||||
`/proc/<pid>/stat`, préfixé de l'identifiant du démarrage ; `ps` ailleurs ;
|
||||
- écriture d'un bloc ;
|
||||
- rendu neutre avant retrait ;
|
||||
- ménage par déplacement-relecture ;
|
||||
- `tune/job.rs` : les routes, le job, les essais et l'application.
|
||||
|
||||
**La reprise reste au Go.** Sa reprise (`tuneRecoverWatch`, toutes les
|
||||
30 s) lit le verrou de la façade et la reconnaît vivante, par la même
|
||||
identité. Si la façade meurt en plein essai, le Go écarte son verrou comme
|
||||
celui d'un « loki tune » tué :
|
||||
|
||||
- l'essai orphelin est arrêté (identité complète vérifiée) ;
|
||||
- une application interrompue est défaite ;
|
||||
- le moteur est relancé s'il tournait.
|
||||
|
||||
La façade fait le même ménage quand elle trouve un verrou périmé en prenant
|
||||
le sien.
|
||||
|
||||
**Les essais restent des « loki serve » du Go**, lancés par la façade avec
|
||||
`LOKI_TUNE_TRIAL` (et `LOKI_TUNE_DRYRUN` à blanc), dans leur propre groupe de
|
||||
processus. Le groupe est arrêté en sortie, quoi qu'il arrive. L'essai est
|
||||
noté dans le verrou avant de charger quoi que ce soit.
|
||||
|
||||
Le verrou de génération reste celui de la conversation du Go. La façade le
|
||||
prend par le **bail du moteur**, avec le motif `tune` :
|
||||
|
||||
- le Go note alors `benching` et donne aux autres le refus de l'optimiseur ;
|
||||
- changer de discussion ne rompt pas le bail ;
|
||||
- un stop du chat, un Reset ou un bail perdu annulent l'optimisation.
|
||||
|
||||
Le Go prête à la façade ce qu'il est seul à voir
|
||||
(`/internal/v1/tune/context`) :
|
||||
|
||||
- **le refus du processus web** : un benchmark (le sien ou celui de la
|
||||
façade), une génération, des jobs d'arrière-plan du mode code ;
|
||||
- **l'état du vrai moteur** (`engineNeedsStop`) ;
|
||||
- **la télémétrie**, d'où viennent les poids d'un tour type.
|
||||
|
||||
Il prête aussi le démarrage et l'arrêt du moteur **sans le refus de
|
||||
l'optimiseur** (`/internal/v1/service/action`, `os`), puisque c'est la
|
||||
façade qui tient le verrou. Si la façade pilote elle-même le moteur
|
||||
(`LOKI_ENGINE=rust`), elle passe par son superviseur.
|
||||
|
||||
Le preset est réécrit et appliqué par les fonctions des presets
|
||||
(`save_applying`, mode `apply` : la configuration, sans redémarrage). Le
|
||||
redémarrage, la sonde et le retour arrière sont ceux de l'optimiseur.
|
||||
|
||||
La ligne de commande (`loki tune`) reste au Go.
|
||||
|
||||
Hors Unix, la façade ne tient pas l'optimiseur. Elle passe alors
|
||||
`LOKI_TUNE` vide au Go, qui le garde et ne relaie rien.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tune_test.go` tire du Go un journal
|
||||
(`tests/parity_tune/core.json`) rejoué par `tests/tune_core.rs`. Il couvre :
|
||||
|
||||
- toute la partie pure ;
|
||||
- le **verrou** : contenu du fichier octet pour octet, clé de prise,
|
||||
comparaison des binaires ;
|
||||
- le **résultat** tel qu'enregistré et montré (listes vides écrites `null`
|
||||
comme une tranche nil du Go) ;
|
||||
- la vérification **après application** (la configuration est-elle l'essai
|
||||
retenu ?).
|
||||
|
||||
`tests/tune.rs` déroule de vraies optimisations, essais compris : le binaire
|
||||
de test joue « loki serve ».
|
||||
|
||||
- À blanc, il écrit la ligne de commande de l'essai.
|
||||
- Pour de vrai, il sert un faux llama-server dont le prefill est d'autant
|
||||
plus rapide que le micro-lot est petit.
|
||||
|
||||
Les tests couvrent :
|
||||
|
||||
- **une optimisation entière** :
|
||||
- bail « tune » et verrou au nom de la façade ;
|
||||
- vrai moteur arrêté puis relancé sans le refus de l'optimiseur, dictée
|
||||
coupée ;
|
||||
- quatre essais composés, lancés, mesurés, l'un retenu ;
|
||||
- copies de configuration sur une adresse privée ;
|
||||
- résultat enregistré par preset et rendu par `/api/tune/last` ;
|
||||
- **l'application** :
|
||||
- dans une copie (rien d'autre ne bouge) ;
|
||||
- dans le preset (réécrit, appliqué, moteur redémarré et sondé) ;
|
||||
- refusée sur un preset changé depuis la mesure ;
|
||||
- **une sonde qui échoue** (contexte perdu) : preset et configuration
|
||||
rétablis, moteur redémarré dessus, sauvegarde faite avant d'écrire ;
|
||||
- **les essais refusés** : à la composition, au chargement (mémoire
|
||||
insuffisante) ; la référence qui ne se compose pas ; la relance qui
|
||||
échoue, dite dans l'erreur ;
|
||||
- **l'annulation**, par la route et par le stop du chat ;
|
||||
- **les refus d'entrée**, avec les messages du Go :
|
||||
- preset externe, Strata, aucun preset actif ;
|
||||
- jobs du mode code, moteur occupé, génération en cours ;
|
||||
- optimisation d'un autre processus (et `elsewhere` dans le statut) ;
|
||||
- **un verrou périmé** :
|
||||
- l'essai orphelin arrêté seulement si son identité concorde ;
|
||||
- une application interrompue défaite ;
|
||||
- les routes sans la façade.
|
||||
|
||||
Les tests du verrou du Go sont portés (`tune/tests.rs`) :
|
||||
|
||||
- prise, refus, essai noté, rendu ;
|
||||
- propriétaire mort ;
|
||||
- verrou à notre PID qui n'est pas l'un des nôtres ;
|
||||
- illisible neuf ou vieux ;
|
||||
- ménage jamais sur un verrou vivant ;
|
||||
- binaire remplacé ;
|
||||
- identité d'un processus ;
|
||||
- arrêt du groupe.
|
||||
|
||||
Côté Go, `TestFacadeTune` vérifie :
|
||||
|
||||
- les cinq routes partent à la façade, et elles seules ;
|
||||
- le bail « tune » : refus, bascule de discussion, libération ;
|
||||
- le contexte : au repos, pendant l'optimisation, pendant un bench ;
|
||||
- le démarrage et l'arrêt sans le refus de l'optimiseur.
|
||||
|
||||
## Mutations
|
||||
|
||||
105 mutations du port Rust (verrou et job). Le premier passage en a tué 39.
|
||||
Les survivantes l'ont été ensuite par les tests ajoutés :
|
||||
|
||||
- `tune/tests.rs` :
|
||||
- un verrou sans PID ;
|
||||
- l'identité d'un processus (démarrage noté, binaire admis) ;
|
||||
- l'application planifiée : clé sur option refusée sans l'option, valeur
|
||||
vide qui retire la clé ;
|
||||
- `tune/job.rs` : l'ETA du suivi, l'arrondi à la minute, le numéro de build ;
|
||||
- `tests/tune.rs` :
|
||||
- **une optimisation entière** :
|
||||
- l'essai noté dans le verrou pendant la mesure ;
|
||||
- l'application refusée pendant la mesure ;
|
||||
- la progression, les confirmations, le gain ;
|
||||
- aucun changement de moteur signalé quand son build est inconnu ;
|
||||
- la cible inconnue ;
|
||||
- **l'application gardée** :
|
||||
- preset qui n'est plus celui en service, API externe ;
|
||||
- clé changée ailleurs pendant l'application (défaite) ;
|
||||
- sauvegarde notée dans le verrou avant d'écrire ;
|
||||
- **les essais** :
|
||||
- écartés (ligne dénaturée, doublon, contexte perdu) ;
|
||||
- muets (délai de chargement) ;
|
||||
- **la mesure** :
|
||||
- VRAM mesurée par un faux nvidia-smi ;
|
||||
- budget atteint, profondeur commune ;
|
||||
- notes et poids tirés de la télémétrie ;
|
||||
- écarts de mesure (sous le seuil, nul, decode plus lent) ;
|
||||
- **le moteur** :
|
||||
- `/props` sans `default_generation_settings` ;
|
||||
- SIDE_SLOT sur un seul slot ;
|
||||
- threads comptés sur les cœurs de la sonde ;
|
||||
- piloté par la façade (LOKI_ENGINE=rust) ;
|
||||
- **les refus** :
|
||||
- un second lancement pendant la prise du bail ;
|
||||
- une application refusée sans journal ;
|
||||
- hors phase d'application, un verrou périmé n'est pas défait.
|
||||
|
||||
Une mutation du premier passage ne changeait rien (`plan_apply(…).and(Ok(()))`
|
||||
garde l'erreur) : elle est remplacée par une qui retire vraiment le contrôle,
|
||||
et tuée.
|
||||
|
||||
Restent vingt équivalentes, ou hors de portée d'un test :
|
||||
|
||||
- **le verrou** :
|
||||
- le fichier retiré quand son écriture échoue après sa création (disque
|
||||
plein), que les tests ne provoquent pas ;
|
||||
- deux re-vérifications du ménage, contre un propriétaire qui revient
|
||||
entre deux lectures : courses seulement ;
|
||||
- la réécriture neutre (essai, moteur actif, sauvegarde effacés) avant le
|
||||
retrait du verrou : sous Unix le retrait suit aussitôt ; elle ne compte
|
||||
que si le retrait échoue ;
|
||||
- **le suivi** :
|
||||
- le résultat montré hors mesure : il est effacé au départ d'une mesure ;
|
||||
- l'annulation n'est posée que pendant une mesure ;
|
||||
- la borne de 6 h du budget, la durée d'un doublon, les étapes restantes :
|
||||
l'estimation seulement ;
|
||||
- l'annulation vérifiée entre deux essais et à la fin : l'essai en cours
|
||||
l'a déjà dite (course) ;
|
||||
- **la mesure** :
|
||||
- VRAM nulle et nvidia-smi absent vont ensemble ;
|
||||
- les candidats générés passent toujours `cfg_check` (garde contre une
|
||||
génération fautive) ;
|
||||
- la fin d'un essai constatée avant d'attendre : un réveil perdu, quand
|
||||
l'essai meurt entre deux attentes (course) ;
|
||||
- l'essai effacé du verrou après son arrêt : le suivant le remplace, ou le
|
||||
verrou est rendu ; laissé, un Loki redémarré vérifie son identité avant
|
||||
tout arrêt ;
|
||||
- **l'application** :
|
||||
- une valeur vide gardée dans la configuration gagnante : `cfg_check` et la
|
||||
réécriture la traitent en clé absente ;
|
||||
- la configuration active comparée à celle de la mesure : les empreintes
|
||||
du preset et de la configuration sont prises ensemble, sur le preset dont
|
||||
l'empreinte est celle de la configuration ; les deux contrôles qui
|
||||
précèdent (preset inchangé, preset en service) l'impliquent ;
|
||||
- le preset « non appliqué » : la configuration a changé entre le contrôle
|
||||
et l'écriture (course) ; le contrôle suivant défait aussi, avec un autre
|
||||
message.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Une sortie à blanc qui échoue est lue dans un fichier où vont les deux
|
||||
sorties (comme `CombinedOutput`), mais une erreur de lecture de
|
||||
`launch.json` ou d'une requête garde le texte de Rust.
|
||||
- Un niveau de raisonnement appris pendant les essais n'est pas transmis au
|
||||
chat du Go quand le Go tient la conversation.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 29 — Télémétrie des complétions de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le Go range chaque complétion dans un anneau en mémoire (`perf_log.go`).
|
||||
Pour chacune, il note :
|
||||
|
||||
- combien du prompt venait du cache, combien a été recalculé ;
|
||||
- à quelle vitesse ;
|
||||
- ce que le décodage spéculatif a gardé.
|
||||
|
||||
Il expose des agrégats sur `/api/perf/summary`. Il calcule aussi la **perte
|
||||
de cache** d'une complétion : ce que le cache aurait dû reprendre de la
|
||||
précédente complétion de la même discussion, et n'a pas repris. Il nomme ce
|
||||
qui s'est intercalé entre les deux, et signale la perte à l'interface
|
||||
au-delà d'un seuil tiré du preset.
|
||||
|
||||
Quand la façade tient la conversation (`LOKI_CHAT=rust`), ses complétions
|
||||
n'apparaissaient nulle part. Le résumé des performances ignorait les tours ;
|
||||
l'optimiseur tirait ses poids d'une télémétrie vide ; l'interface ne
|
||||
recevait jamais la perte de cache.
|
||||
|
||||
## Le lot
|
||||
|
||||
Les complétions de la façade rejoignent l'anneau du Go, par un seul point
|
||||
d'entrée, `/internal/v1/turn/perf`. Il est arrivé sur `main` en même temps que
|
||||
ce lot (« Conversation tenue par Rust : télémétrie et requêtes en vol comme
|
||||
le Go »), et la fusion n'en garde qu'un.
|
||||
|
||||
- `TurnHost::perf_record` range une complétion. La boucle du tour l'appelle :
|
||||
- après chaque complétion lue, complète ou non, numérotée dans le tour
|
||||
(`perfIter`) ;
|
||||
- avec les empreintes seulement si elle est complète (`perf_prefix`,
|
||||
FNV-1a cumulé des messages envoyés) ;
|
||||
- pour une complétion coupée par un stop, rangée incomplète.
|
||||
|
||||
La dernière statistique d'une complétion complète porte ce que le Go en
|
||||
dit : la nature, la perte de cache, ce qui s'est intercalé et l'alerte.
|
||||
- La nature, posée par l'hôte :
|
||||
- `main` pour un tour et pour les corrections du builder du mode Code ;
|
||||
- `verify` pour la passe de vérification isolée ;
|
||||
- `compact` pour un résumé de compaction, rangé dès que sa réponse se lit,
|
||||
sans empreintes.
|
||||
- `chat/perf.rs` porte ce que la façade relève elle-même : la réponse entière
|
||||
d'un résumé (`perfRecFromWire`, sans échec possible, avec les mêmes
|
||||
tolérances que `perfNum`), traduite en statistiques d'une complétion.
|
||||
- Le Go range le relevé comme les siens (`perfRecFromStats`) : numéro,
|
||||
instant, perte de cache, ce qui s'est intercalé. Il calcule l'alerte au
|
||||
seuil du preset actif (`perfLostAlertAt`), jamais pour un preset externe.
|
||||
- La sonde du gabarit n'en part pas : la façade la tient (lot 23) et la lance
|
||||
elle-même après une complétion complète de forme normale. Le Go lui demande
|
||||
son verdict (`facadeTplCaps`).
|
||||
|
||||
`/api/perf/summary` reste au Go, qui tient l'anneau.
|
||||
|
||||
## Tests
|
||||
|
||||
- `chat/perf.rs` :
|
||||
- relevé d'une réponse entière : nombres en chaîne, valeurs hors bornes,
|
||||
octet parasite, cache d'OpenAI, timings d'un autre type ;
|
||||
- empreintes cumulées, FNV-1a de référence.
|
||||
- La boucle du tour :
|
||||
- deux complétions numérotées, totaux, empreintes qui se prolongent ;
|
||||
- dernière statistique avec la nature et la perte vues par le Go ;
|
||||
- complétion coupée rangée incomplète, sans statistique finale.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- complétions d'un tour rangées par le Go, rattachées à la discussion ;
|
||||
- résumés de compaction (rangés si la réponse se lit, pas sinon).
|
||||
- Le Go (`web_internal_turnperf_test.go`) :
|
||||
- complétion rangée dans l'anneau ;
|
||||
- perte et ce qui s'est intercalé, alerte ;
|
||||
- preset externe sans alerte, complétion incomplète sans empreintes ;
|
||||
- nature d'un résumé gardée, un tour par défaut ;
|
||||
- corps illisible, GET refusé.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les empreintes sont calculées sur le JSON des messages tel que Rust
|
||||
l'écrit (sans l'échappement HTML du Go). Elles ne se comparent qu'entre
|
||||
complétions de la même discussion et de la même nature, toutes produites
|
||||
par la façade.
|
||||
- Une complétion coupée par un stop est rangée sans ses compteurs partiels.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Lot 30 — Motifs de relance personnalisés tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Quand le modèle écrit un appel d'outil en toutes lettres au lieu de
|
||||
l'émettre par le protocole, Loki le reconnaît et relance le tour avec une
|
||||
consigne corrective. Les motifs embarqués couvrent les formats connus. Un
|
||||
expert peut les remplacer par la clé d'état `retry_patterns` :
|
||||
|
||||
- c'est un tableau JSON de regex du Go, chacune compilée en `(?mi)` ;
|
||||
- une regex illisible est sautée ;
|
||||
- une surcharge illisible ou vide laisse les motifs embarqués.
|
||||
|
||||
Jusqu'ici, une surcharge posée gardait la conversation au Go
|
||||
(`facadeChatBlocker`), même avec `LOKI_CHAT=rust`.
|
||||
|
||||
## Le lot
|
||||
|
||||
La façade lit la surcharge au démarrage de sa conversation
|
||||
(`chat::generate::retry_patterns`), comme le Go la compile une fois par
|
||||
processus. Le Go ne garde plus la conversation pour cette clé.
|
||||
|
||||
Les regex sont celles du Go (paquet `regexp`), pas celles de `regex`.
|
||||
`chat::stream::go_regex` les réécrit pour garder le sens du Go :
|
||||
|
||||
- les classes Perl et les limites de mot du Go sont ASCII ; celles de
|
||||
`regex` sont Unicode :
|
||||
- hors d'une classe : `\w`, `\W`, `\d`, `\D`, `\s`, `\S` deviennent des
|
||||
classes ASCII ;
|
||||
- dans une classe : leur équivalent dans la classe ;
|
||||
- `\b`, `\B` deviennent `(?-u:\b)`, `(?-u:\B)` ;
|
||||
- `\Q…\E` (texte littéral) devient le texte échappé ; le Go le refuse dans
|
||||
une classe, la traduction aussi ;
|
||||
- dans une classe, `[`, `&&`, `~~` sont littéraux pour le Go, et `--` est un
|
||||
intervalle : ils sont échappés ;
|
||||
- l'octal du Go (`\101`) est permis (`octal(true)`). Une référence arrière
|
||||
(`\1`), que le Go refuse, est refusée.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_retry_test.go` compile côté Go une soixantaine de
|
||||
regex (dont celles qu'il refuse). Il note leur première correspondance sur
|
||||
onze textes : accents, chiffres non ASCII, espaces insécables, crochets,
|
||||
majuscules accentuées. Le journal (`tests/parity_retry/patterns.json`) est
|
||||
rejoué par `tests/retry.rs` : même compilation, même correspondance, même
|
||||
position.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- la lecture de la surcharge : absente, `null`, vide, mal typée, suivie de
|
||||
texte ;
|
||||
- les regex sautées, les correspondances ;
|
||||
- la surcharge lue au démarrage de la conversation de la façade ;
|
||||
- côté Go, la clé ne garde plus la conversation.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Lot 31 — Slots du moteur pour le tour tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Trois clés opt-in du moteur local gardaient la conversation au Go
|
||||
(`facadeChatBlocker`), faute d'être honorées par la façade :
|
||||
|
||||
- `SIDE_SLOT` (`llm_sideslot.go`) : un second slot pour les travaux annexes.
|
||||
Chaque requête de Loki porte son `id_slot` : 0 pour le fil de la
|
||||
discussion (étape d'un tour, résumé en continuation), 1 pour le reste
|
||||
(vérification, résumé sur transcription). Un cache KV à court de place en
|
||||
plein calcul est rejoué tel quel, sans compaction : c'est la faute du
|
||||
voisin, pas un prompt trop long.
|
||||
- `SLOT_PERSIST` (`llm_slotpersist.go`) : l'état du slot de la discussion est
|
||||
gardé sur disque à la bascule de preset, et rechargé avant la première
|
||||
requête de la discussion au retour.
|
||||
|
||||
Et l'isolation des travaux annexes (`engineSideJob`, `CACHE_ISOLATE`) : après
|
||||
la passe de vérification du mode Code, le Go efface le slot local s'il a
|
||||
servi. La façade la sautait (écart du lot 7), le Go ne voyant pas passer ses
|
||||
requêtes.
|
||||
|
||||
Depuis la fusion de `main`, le Go voit passer chaque requête de la façade au
|
||||
moteur local (tickets « en vol », `/internal/v1/oai/begin`, `served`, `end`).
|
||||
La machinerie peut donc rester au Go ; il suffit que la façade la consulte.
|
||||
|
||||
## Le lot
|
||||
|
||||
### Go
|
||||
|
||||
- `/internal/v1/turn/slot` (`facade_slots.go`) :
|
||||
- entrée : `{"conv":"…","main":true}` ;
|
||||
- sortie : `{"slot":0|1|-1}`, la valeur d'`engineSlotFor` ;
|
||||
- avec `main` et `conv`, recharge d'abord l'état gardé de la discussion
|
||||
(`slotPersistRestore`), comme la boucle Go avant une requête d'un tour.
|
||||
- La sauvegarde à la bascule ne change pas. Le slot porte la discussion dès
|
||||
que le moteur accepte une étape de son tour : `/internal/v1/oai/served`
|
||||
appelle `engineMarkMain`.
|
||||
- `SIDE_SLOT` et `SLOT_PERSIST` ne gardent plus la conversation.
|
||||
|
||||
### Rust
|
||||
|
||||
- `TurnHost::engine_slot`, appelé avant chaque requête. La boucle pose
|
||||
l'`id_slot` rendu dans le corps.
|
||||
- Le rejeu « cache KV plein » (`sideSlotPoolError`) n'a lieu qu'avec un slot
|
||||
en service et un moteur local :
|
||||
- deux fois au plus, après l'attente des reprises réseau ;
|
||||
- puis l'erreur telle quelle ;
|
||||
- ni compaction ni réduction.
|
||||
- `GoTurnHost` :
|
||||
- moteur externe : aucun slot ;
|
||||
- étape d'un tour : `main` avec la discussion (donc le rechargement
|
||||
`SLOT_PERSIST`) ;
|
||||
- vérification : le second slot, et le ticket « en vol » ouvert sur lui
|
||||
(`side`).
|
||||
- `EngineSummarizer` : un résumé est désormais compté en vol, comme
|
||||
`postSummary`. Il part sur le second slot s'il est fait sur la
|
||||
transcription, sur le slot 0 s'il est fait en continuation.
|
||||
- La passe de vérification est encadrée par `/internal/v1/engine/sidejob`.
|
||||
À la fin, le Go efface le slot si le moteur a servi.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go (`facade_slots_test.go`) :
|
||||
- sans la clé, aucun slot et `/props` jamais lu ;
|
||||
- fil de la discussion sur le 0, le reste sur le 1 ;
|
||||
- second slot hors service ;
|
||||
- rechargement une seule fois, pour la bonne discussion et une étape de tour
|
||||
seulement ;
|
||||
- une étape de la façade acceptée par le moteur suffit à la sauvegarde ;
|
||||
- corps illisible, GET refusé.
|
||||
- La boucle du tour :
|
||||
- `id_slot` dans chaque requête, slot demandé avant chaque envoi ;
|
||||
- cache KV plein : rejoué deux fois sans compaction, puis l'erreur ;
|
||||
- sans slot, ou moteur externe : un débordement comme un autre ;
|
||||
- motif `sideSlotPoolError` repris des cas du Go.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- contrat du mode Code : le tour sur le 0, la vérification sur le 1 ;
|
||||
- tickets ouverts sur le second slot, travail annexe ouvert puis rendu ;
|
||||
- résumés : transcription sur le 1, continuation sur le 0, tickets rendus.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Le rejeu « cache KV plein » se fie au slot demandé pour cette requête. Le Go
|
||||
relit `sideSlotLive` au moment de l'erreur ; les deux lectures partagent
|
||||
le même cache de dix secondes.
|
||||
- Le slot est demandé avant de composer la requête, et non juste avant de
|
||||
l'envoyer : rien ne se passe entre les deux.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 32 — Images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Sans la clé, une image montrée par un outil (capture, `see_image`) est
|
||||
éphémère. Le modèle la voit pendant le tour, puis elle disparaît de
|
||||
l'historique. Le tour suivant renvoie donc un fil où elle manque : le
|
||||
préfixe en cache diverge à elle, et toute la boucle d'outils qui suivait est
|
||||
recalculée.
|
||||
|
||||
`KEEP_TURN_IMAGES` (`chat_keep_images.go`) garde l'image telle qu'envoyée,
|
||||
sous conditions :
|
||||
|
||||
- vision réellement active ;
|
||||
- image rangée par référence et relue à l'octet près ;
|
||||
- coût **mesuré** par le moteur, jamais estimé ;
|
||||
- toutes les images gardées tiennent dans 10 % de la fenêtre.
|
||||
|
||||
Les images gardées partent avant tout résumé. La clé gardait la conversation
|
||||
au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
### Go (`facade_keepimg.go`)
|
||||
|
||||
- `/internal/v1/turn/keepimg` rend deux choses :
|
||||
- si la vision est réellement active (`visionEnabled`, `engineSeesImages`) ;
|
||||
- le budget des images gardées (`keepImgBudget`).
|
||||
- `/internal/v1/turn/keepimg/store` range une image relayée par référence
|
||||
(`refRelayImage`) :
|
||||
- fichier chiffré si la mémoire l'est ;
|
||||
- date rafraîchie ;
|
||||
- relu à l'octet près.
|
||||
|
||||
Il rend le message qui la porte ainsi et son poids en octets, ou un refus.
|
||||
- `KEEP_TURN_IMAGES` ne garde plus la conversation.
|
||||
|
||||
### Rust
|
||||
|
||||
- `chat/keepimg.rs` porte l'état du tour (`keepImages`) :
|
||||
- `relay` : image gardée à l'essai, dans `messages` et `extra` ;
|
||||
- `sending` ;
|
||||
- `observe` : mesure de la requête qui l'a montrée la première, partage au
|
||||
prorata des octets, seuil de 16 jetons, budget ;
|
||||
- `rewritten` ;
|
||||
- `publishable` ;
|
||||
- `finish` : une image à l'essai jamais mesurée redevient éphémère.
|
||||
|
||||
Une image refusée arrête le tour pour les suivantes.
|
||||
- La boucle du tour :
|
||||
- `TurnHost::keep_images` donne le budget, mais seulement pour le fil
|
||||
principal d'une discussion, avec la clé et la vision active ;
|
||||
- `TurnHost::keep_image` range l'image par le Go ;
|
||||
- l'historique réécrit en cours de tour est publié sans les relais non
|
||||
gardés.
|
||||
- Les images gardées partent d'abord, avant tout résumé :
|
||||
- au filet réactif (prompt trop long) : requête rejouée sans elles ;
|
||||
- en cours de tour, au seuil : la compaction ne suit que si elle reste
|
||||
nécessaire ;
|
||||
- sur une complétion coupée par une fenêtre pleine : rejouée sans résumé
|
||||
si leur retrait suffit.
|
||||
- Début et fin de tour (`keptImagesTurnStart`) : les images gardées sont
|
||||
retirées si le contexte le demande, ou si elles ne doivent plus partir
|
||||
(clé retirée, vision absente).
|
||||
|
||||
La compaction et la réduction forcée retiraient déjà les images gardées
|
||||
d'abord (`chat/compact.rs`).
|
||||
|
||||
## Tests
|
||||
|
||||
- `chat/keepimg` :
|
||||
- clé ;
|
||||
- gardée au coût mesuré ;
|
||||
- budget ;
|
||||
- sans mesure (pas d'usage, complétion coupée, pas de base) ;
|
||||
- requête relancée ;
|
||||
- rangement refusé ;
|
||||
- partage entre deux images ;
|
||||
- publication, réécriture, fin de tour, budget plein avant rangement.
|
||||
- La boucle du tour :
|
||||
- requête identique avec et sans la clé, image gardée par référence au
|
||||
coût mesuré ;
|
||||
- refus : budget, pas d'usage, rangement refusé ;
|
||||
- image à l'essai en fin de tour ;
|
||||
- retrait en cours de tour, au filet réactif et sur une fenêtre pleine, à
|
||||
chaque fois sans résumé.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- image rangée par le Go, vue en data-URL par le moteur, gardée avec son
|
||||
coût ;
|
||||
- clé retirée : l'image part au début du tour suivant, légende et marque
|
||||
gardées.
|
||||
- Le Go (`facade_keepimg_test.go`) :
|
||||
- état sans projecteur puis vu par le moteur, budget ;
|
||||
- rangement relu à l'octet près ;
|
||||
- refus : texte seul, URL distante, format inconnu, base64 illisible, deux
|
||||
images ;
|
||||
- corps illisible, GET refusé.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Lot 33 — Préchauffage (PREWARM) tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Certains recalculs sont inévitables :
|
||||
|
||||
- le prompt réécrit par une compaction ;
|
||||
- le dernier message assistant rendu autrement au tour suivant ;
|
||||
- la discussion reprise après une tâche qui a pris le slot.
|
||||
|
||||
Sans la clé, ils tombent sur le premier jeton du message suivant.
|
||||
|
||||
`PREWARM` (`chat_prewarm.go`) envoie au moteur local, dès qu'il est libre, la
|
||||
requête du prochain tour telle qu'elle partira, suivie d'une sentinelle « . »
|
||||
et avec `max_tokens` 1. Le moteur calcule tout le préfixe ; le vrai tour ne
|
||||
calcule plus que son propre message.
|
||||
|
||||
Le préchauffage ne passe jamais devant un vrai travail :
|
||||
|
||||
- toute requête de Loki l'annule, sauf un tour qui le prolonge exactement ;
|
||||
- un seul est en vol à la fois ;
|
||||
- aucune erreur ne remonte.
|
||||
|
||||
La clé gardait la conversation au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
La façade tient l'historique : c'est elle qui assemble et envoie. Le Go
|
||||
garde le compteur des requêtes en vol (`engineGate`) et ce qui annule.
|
||||
|
||||
### Go (`facade_prewarm.go`)
|
||||
|
||||
- `/internal/v1/prewarm/check` dit si le préchauffage peut partir :
|
||||
- clé posée, moteur local ;
|
||||
- ni travail annexe, ni bench, ni optimiseur en cours ;
|
||||
- loin de minuit ;
|
||||
- moteur en bonne santé ;
|
||||
- slot visé : un seul slot, ou celui de la discussion avec SIDE_SLOT.
|
||||
- `/internal/v1/prewarm/begin` :
|
||||
- recharge l'état SLOT_PERSIST de la discussion ;
|
||||
- inscrit le préchauffage comme LA requête en vol (`prewarmBegin`) ;
|
||||
- son annulation (une autre requête part) est relayée à la façade
|
||||
(`/internal/facade/v1/chat/prewarm/cancel`).
|
||||
- `/internal/v1/prewarm/end` :
|
||||
- rend la place ;
|
||||
- note ce que le moteur a repris (`noteEnginePrompt`).
|
||||
- `/internal/v1/oai/begin` accepte `prewarm_keep` : l'étape d'un tour de la
|
||||
façade qui prolonge le préchauffage ne l'annule pas.
|
||||
- Après une tâche planifiée, le déclencheur part à la façade
|
||||
(`/internal/facade/v1/chat/prewarm`).
|
||||
- PREWARM ne garde plus la conversation.
|
||||
|
||||
### Rust (`chat/prewarm.rs`)
|
||||
|
||||
- L'état partagé :
|
||||
- le préchauffage en vol, son inscription, son annulation ;
|
||||
- les capacités du dernier tour de chaque discussion (64 au plus).
|
||||
- L'assemblage : les mêmes fonctions que le tour.
|
||||
- Vue du Go, historique sans blocs `<context_update>`, préambule.
|
||||
- Écho, messages d'envoi, corps.
|
||||
- Sentinelle « . » (rôle `builder` en mode Code), non streamé, un seul jeton.
|
||||
- On s'abstient dans ces cas :
|
||||
- capacités inconnues ;
|
||||
- tour en cours ;
|
||||
- discussion vide ;
|
||||
- compaction attendue ;
|
||||
- assemblage inattendu.
|
||||
- Déclencheurs :
|
||||
- fin de tour quand rien n'attend dans la file ;
|
||||
- fin de tâche (relayée par le Go) ;
|
||||
- changement de discussion ou de projet : le préchauffage en cours est
|
||||
annulé ; avec `full`, la nouvelle discussion est préparée après 3 s.
|
||||
- Le tour :
|
||||
- `TurnHost::prewarm_keeps` compare les empreintes des messages envoyés
|
||||
et des champs qui façonnent le rendu (`prewarmOptsHash`) ;
|
||||
- une requête qui ne prolonge pas le préchauffage le coupe tout de suite,
|
||||
sans attendre l'aller-retour par le Go.
|
||||
- Télémétrie : le préchauffage est rangé dans l'anneau (`kind=prewarm`).
|
||||
|
||||
## Tests
|
||||
|
||||
- `chat/prewarm` :
|
||||
- mode (jamais pour un preset externe) ;
|
||||
- empreinte des options (l'échantillonnage n'y entre pas, un champ absent
|
||||
n'est pas un champ vide) ;
|
||||
- rôle de la sentinelle ;
|
||||
- prolongement ;
|
||||
- état partagé (un seul en vol, garde, annulation, fin) ;
|
||||
- capacités bornées.
|
||||
- La boucle du tour : le drapeau transmis à l'envoi, jamais vers une API
|
||||
externe.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- fin de tour : requête préchauffée (sentinelle, un seul jeton, même
|
||||
préfixe et mêmes outils que le tour), inscrite au Go, pas de ticket de
|
||||
la façade ;
|
||||
- le tour suivant la prolonge et la garde (`prewarm_keep`) ;
|
||||
- fin rendue avec total et cache, télémétrie ;
|
||||
- refus du Go : rien ne part ;
|
||||
- annulation relayée : connexion coupée, place rendue.
|
||||
- Le Go (`facade_prewarm_test.go`) :
|
||||
- contrôle : clé, slots, minuit, travail annexe, moteur muet ;
|
||||
- inscription et deuxième préchauffage refusé ;
|
||||
- gardé par le tour qui le prolonge, annulé et relayé sinon ;
|
||||
- fin unique ;
|
||||
- requête déjà en vol ;
|
||||
- déclencheur après tâche relayé.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- L'annulation d'un préchauffage pas encore inscrit au Go (aller-retour en
|
||||
cours) annule celui qui part, quel que soit son numéro : il n'y en a
|
||||
qu'un.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lot 34 — Bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le contexte du projet part en tête du premier message utilisateur :
|
||||
|
||||
- description ;
|
||||
- index mémoire ;
|
||||
- trackers ;
|
||||
- consignes du dépôt ;
|
||||
- avec la clé, la date et le dossier de travail.
|
||||
|
||||
Reconstruit à chaque tour, il change dès qu'une page est créée ou qu'un
|
||||
tracker reçoit un point. Tout ce qui le suit dans le prompt est alors
|
||||
recalculé.
|
||||
|
||||
`PROJ_SNAPSHOT` (`chat_projsnap.go`) garde une copie figée du bloc par
|
||||
discussion. Les changements arrivent en `<context_update>` en tête du
|
||||
nouveau message utilisateur, rangés tels quels dans l'historique. Le bloc
|
||||
n'est rafraîchi que quand le début du prompt change de toute façon :
|
||||
compaction, système ou outils, clé du moteur, projet, mode, trop de mises à
|
||||
jour accumulées.
|
||||
|
||||
C'était le dernier réglage qui gardait la conversation au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
L'instantané est déjà persisté avec la discussion (`ConvState::proj_snap`),
|
||||
et la compaction le remet à zéro comme le Go. Il manquait la décision, qui
|
||||
lit le contexte du projet : elle reste au Go.
|
||||
|
||||
### Go (`facade_projsnap.go`)
|
||||
|
||||
- `/internal/v1/turn/view`, avec la clé et l'agent :
|
||||
- reçoit l'instantané de la discussion (`snap`) ;
|
||||
- rend la tête du tour (prompt du preset, puis le bloc figé, ou le vivant
|
||||
s'il faut rafraîchir) et le préambule sans date ni dossier
|
||||
(`envInCtx`) ;
|
||||
- rend aussi `projsnap` : rafraîchir ou non, la mise à jour à poser,
|
||||
l'instantané à ranger, la clé et l'empreinte du système du tour.
|
||||
- `dry` (préchauffage) : la même décision, rien à ranger.
|
||||
- `/internal/v1/turn/projsnap/refresh` : le bloc vivant et l'instantané qu'il
|
||||
devient (`projSnapTurn.refresh`).
|
||||
- Plus aucun réglage ne garde la conversation : `facadeChatBlocker` et la
|
||||
décision prise une fois par processus disparaissent. Le Go la remet dès
|
||||
que `LOKI_CHAT=rust`, l'adresse de la façade et le jeton sont là.
|
||||
|
||||
### Rust
|
||||
|
||||
- `ChatService::assemble_turn`, partagé par le tour et la relance du builder
|
||||
du mode Code :
|
||||
- rafraîchi : instantané neuf, blocs `<context_update>` retirés de
|
||||
l'historique ;
|
||||
- mise à jour : posée en tête du dernier message utilisateur
|
||||
(`prependCtxUpdate`), dans la discussion et dans la requête ; l'état
|
||||
annoncé ne bouge que si elle a trouvé où se poser ;
|
||||
- sans la clé : la discussion qui porte encore des blocs en est nettoyée,
|
||||
comme avant.
|
||||
- `TurnHost::project_refresh` : après une compaction ou une réduction en
|
||||
plein tour, le bloc vivant remplace le figé (`replaceProjectHead`) et les
|
||||
mises à jour sont retirées. L'instantané posé devient celui de la
|
||||
discussion à la fin du tour ou de la passe. Le retrait d'images gardées ne
|
||||
touche pas au début du prompt : pas de rafraîchissement.
|
||||
- Le préchauffage demande la vue en `dry`. Il garde les mises à jour de
|
||||
l'historique tant que le bloc n'est pas rafraîchi, comme le vrai tour.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go (`facade_projsnap_test.go`) :
|
||||
- la séquence assemblée par la façade égale celle de `turnView`, outils et
|
||||
instantané compris ;
|
||||
- une page ajoutée : bloc gardé, mise à jour égale à celle de `turnView`,
|
||||
état annoncé suivi ;
|
||||
- sans changement : rien à poser ;
|
||||
- `dry` sans rien à ranger ;
|
||||
- clé changée : rafraîchi, bloc vivant en préchauffage ;
|
||||
- rafraîchissement en plein tour : bloc vivant, instantané à la clé du
|
||||
tour.
|
||||
- Go (`facade_chat_test.go`) : avec toutes les clés opt-in posées, la façade
|
||||
tient la conversation ; sans adresse ou sans `LOKI_CHAT`, le Go et
|
||||
pourquoi.
|
||||
- Rust, `chat/compact` : mise à jour en tête (texte, parties, refus) ; bloc
|
||||
projet remplacé à sa place (cas du Go).
|
||||
- Rust, boucle du tour : rafraîchi après une compaction et une réduction ;
|
||||
pas après un retrait d'images gardées.
|
||||
- Rust, vrai hôte (`tests/lot6.rs`) :
|
||||
- instantané rangé ;
|
||||
- mise à jour posée dans la requête et l'historique (marque retirée à
|
||||
l'envoi) ;
|
||||
- rafraîchissement qui retire les mises à jour ;
|
||||
- compaction en plein tour : bloc vivant envoyé, instantané rangé ;
|
||||
- préchauffage en `dry`.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Lot 35 — Routes de base par le tunnel du relais
|
||||
|
||||
## Contexte
|
||||
|
||||
La façade sert toujours neuf routes, sans variable (`api.rs`) :
|
||||
|
||||
- `/api/ping` ;
|
||||
- `/api/status` ;
|
||||
- `/api/config` ;
|
||||
- `/api/engine/cmdline` ;
|
||||
- `/api/model/layers` ;
|
||||
- `/api/paths` ;
|
||||
- `/api/ram` ;
|
||||
- `/api/vram` ;
|
||||
- `/api/service/log`.
|
||||
|
||||
Mais le tunnel du relais (accès distant, E2E) sert le mux du Go tel quel.
|
||||
`withFacadeChat` ne remet à la façade que les routes des lots. Un utilisateur
|
||||
distant tombait donc encore sur les handlers Go de ces neuf routes : deux
|
||||
implémentations à garder d'accord.
|
||||
|
||||
## Le lot
|
||||
|
||||
- Go (`facade_core.go`) : une table des routes de base, remises à la façade
|
||||
par `withFacadeChat` dès qu'elle tourne (adresse et jeton interne
|
||||
présents), sans variable. Façade muette : 502 « façade indisponible ».
|
||||
Sans façade (`LOKI_FACADE=0`), le Go les sert lui-même.
|
||||
- Rust (`api.rs`) : une requête relayée par le Go (jeton interne) a déjà été
|
||||
authentifiée par lui. Ni clé ni hôte local ne sont exigés de nouveau,
|
||||
comme pour les routes des lots ; la version du Go est lue quand même pour
|
||||
`/api/ping`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go (`facade_core_test.go`) :
|
||||
- les neuf routes relayées avec le jeton ;
|
||||
- une route du Go gardée ;
|
||||
- façade muette ;
|
||||
- sans façade, servies ici.
|
||||
- Rust (`tests/lot2.rs`) :
|
||||
- relayée par le Go avec un hôte distant et une clé posée : servie par la
|
||||
façade, pas renvoyée au Go ;
|
||||
- sans jeton, ou avec un faux : refusée.
|
||||
|
||||
## Reste à faire
|
||||
|
||||
L'interface statique et `/v1` passent encore par le Go sur le tunnel. Ses
|
||||
handlers sont gardés pour `LOKI_FACADE=0`.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Lot 36 — Appels d'outils MCP sans détour par le Go
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 20, la façade tient le pool MCP (`LOKI_MCP=rust`) : sessions
|
||||
stdio et HTTP, registre des outils exposés. Le tour de la façade, lui,
|
||||
faisait encore exécuter un appel `mcp__*` par le Go
|
||||
(`/internal/v1/tool/exec`).
|
||||
|
||||
Le Go ne l'exécutait pas lui-même : `mcpCall` renvoyait l'appel à la façade
|
||||
(`/internal/facade/v1/mcp/call`). Chaque appel faisait donc deux
|
||||
allers-retours locaux pour revenir au même pool.
|
||||
|
||||
Pour un outil MCP, le Go n'ajoute rien au résultat (`toolStepExtras`) :
|
||||
|
||||
- ni capture ;
|
||||
- ni aperçu du navigateur ;
|
||||
- ni image relayée.
|
||||
|
||||
Seul le texte revient.
|
||||
|
||||
## Le lot
|
||||
|
||||
- `ChatShared` porte le pool (`mcp`), le même que celui des routes (créé une
|
||||
fois dans `main.rs`).
|
||||
- `GoTurnHost::exec_tool` : un nom `mcp__*`, pool tenu → `Mcp::call`
|
||||
directement. Le résultat est celui de la route interne :
|
||||
- texte aplati et coupé ;
|
||||
- « [l'outil a renvoyé une erreur] » ;
|
||||
- « [ok] (aucune sortie) » ;
|
||||
- une reconnexion si la session est morte, puis « [erreur MCP] … ».
|
||||
- Une reconnexion annonce la version du Go dans `clientInfo`, comme la route
|
||||
interne. Elle est lue une fois (`/internal/v1/auth`) et retenue.
|
||||
- Sans pool tenu (`LOKI_MCP` absent), rien ne change : l'appel part au Go,
|
||||
qui l'exécute avec son propre pool.
|
||||
|
||||
Le Go garde la route interne pour les tours qu'il exécute encore :
|
||||
|
||||
- sous-agents ;
|
||||
- tâches IA ;
|
||||
- discussion rendue au Go.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/lot6.rs` (`outil_mcp_par_le_pool_de_la_facade`) fait tourner un faux
|
||||
serveur MCP stdio. Un tour de la façade appelle :
|
||||
|
||||
- un outil qui répond ;
|
||||
- un outil dont le serveur meurt.
|
||||
|
||||
Le test vérifie :
|
||||
|
||||
- aucun appel au Go ;
|
||||
- le résultat de chaque outil tel que le modèle le reçoit ;
|
||||
- l'erreur après une reconnexion ;
|
||||
- la version du Go annoncée à cette reconnexion.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 37 — Outils task_* de l'agent en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 26, la façade tient les tâches planifiées (`LOKI_TASKS=rust`) :
|
||||
|
||||
- les routes ;
|
||||
- le planificateur ;
|
||||
- les tâches « script seul ».
|
||||
|
||||
Les quatre outils par lesquels le modèle gère lui-même ses tâches
|
||||
(`tasks_tools.go`) restaient au Go, appelés par `/internal/v1/tool/exec` :
|
||||
|
||||
- `task_list` ;
|
||||
- `task_create` ;
|
||||
- `task_update` ;
|
||||
- `task_delete`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/tasks/tools.rs` porte les quatre outils, mêmes messages au caractère
|
||||
près :
|
||||
|
||||
- **`task_list`** :
|
||||
- liste du projet actif, aperçu d'une ligne par tâche ;
|
||||
- compte-rendu coupé à 300 octets, un caractère coupé valant U+FFFD par
|
||||
octet comme à l'encodage JSON du Go ;
|
||||
- avec un id, la tâche seule et son compte-rendu complet ;
|
||||
- une tâche d'un autre projet est « introuvable » ;
|
||||
- **`task_create`** :
|
||||
- tâche unique « dans N minutes » (durée tronquée comme
|
||||
`time.Duration(v)`) ou « à HH:MM » (le lendemain si l'heure est passée,
|
||||
à la même heure murale) ou « à AAAA-MM-JJ HH:MM » ;
|
||||
- fréquence validée ;
|
||||
- script vérifié dans le dossier protégé ;
|
||||
- tâche rattachée au projet actif ;
|
||||
- fuseau : celui du navigateur, sinon celui de la première tâche qui en a
|
||||
un ;
|
||||
- **`task_update`** : modification partielle ; la prochaine exécution est
|
||||
recalculée sur une nouvelle fréquence, ou à la reprise d'une tâche qui
|
||||
n'en a pas ;
|
||||
- **`task_delete`**.
|
||||
|
||||
Un projet disparu vaut le projet par défaut (`taskProjectOf`).
|
||||
|
||||
`GoTurnHost::exec_tool` les exécute quand la façade tient les tâches
|
||||
(`ChatShared.tasks`, la même base que celle des routes). Sinon l'appel part
|
||||
au Go comme avant.
|
||||
|
||||
Côté Go, les outils prennent l'heure des tâches (`tasksNow`) au lieu de
|
||||
`time.Now`. Rien ne change en service ; le journal de parité peut ainsi la
|
||||
fixer.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tasks_tools_test.go` tire du Go un journal de 44
|
||||
appels (`tests/parity_tasks/tools.json`). Chaque étape porte :
|
||||
|
||||
- l'heure, le fuseau, le projet actif, les projets connus ;
|
||||
- les scripts présents ;
|
||||
- une tâche posée telle quelle en base ;
|
||||
- les arguments tels que le modèle les envoie ;
|
||||
- le résultat tel qu'il le reçoit ;
|
||||
- les tâches en base après coup.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- l'heure du trou du changement d'heure ;
|
||||
- une date passée ou impossible ;
|
||||
- des arguments d'un autre type ;
|
||||
- un script absent, hostile ou dossier ;
|
||||
- le compte-rendu coupé au milieu d'un caractère ;
|
||||
- le fuseau d'une tâche existante.
|
||||
|
||||
`tests/tasks.rs` (`parite_des_outils`) le rejoue contre le port Rust.
|
||||
|
||||
`tests/lot6.rs` (`outil_task_par_la_facade`) fait tourner un tour de la
|
||||
façade qui crée une tâche. Il vérifie :
|
||||
|
||||
- rien ne passe par le Go ;
|
||||
- la tâche est rangée au fuseau de l'utilisateur, dans le projet actif ;
|
||||
- le résultat est celui que le modèle reçoit.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Lot 38 — L'outil criteria du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le modèle tient les critères d'acceptation de sa tâche avec
|
||||
l'outil `criteria` :
|
||||
|
||||
- les poser ;
|
||||
- les marquer faits ;
|
||||
- les lister ;
|
||||
- les effacer.
|
||||
|
||||
La passe de vérification les marque ensuite `passed` ou `failed`.
|
||||
|
||||
Depuis le lot 19, la façade sert déjà les critères de l'écran
|
||||
(`/api/chat/criteria`, `chat/crit:<id>` scellés). La passe de vérification
|
||||
les relit depuis le lot 7. Mais l'outil lui-même partait au Go
|
||||
(`/internal/v1/tool/exec`), qui le renvoyait à la façade comme delta
|
||||
(`critNotify` → `/internal/facade/v1/delta`).
|
||||
|
||||
## Le lot
|
||||
|
||||
`chat/crittool.rs` porte `toolCriteria`, mêmes messages au caractère près :
|
||||
|
||||
- **list** : action vide ou d'un autre type comprise ;
|
||||
- **add** :
|
||||
- textes vides ou d'un autre type ignorés ;
|
||||
- ids repris après le plus grand ;
|
||||
- vingt critères au plus ;
|
||||
- **set** :
|
||||
- id numérique (tronqué) ou en chaîne (« "2" », « "#2" », « +1 ») ;
|
||||
- statut libre, note rognée ;
|
||||
- `passed` refusé hors de la passe de vérification ;
|
||||
- **clear**.
|
||||
|
||||
Les règles de rangement et d'affichage :
|
||||
|
||||
- une liste vide supprime la clé ; une erreur d'écriture est ignorée, comme
|
||||
dans le Go ;
|
||||
- une liste rangée illisible vaut « aucun critère » ;
|
||||
- la note vide n'est pas écrite (`omitempty`).
|
||||
|
||||
`GoTurnHost::exec_tool` l'exécute pour la discussion du tour
|
||||
(`main_conv`). La passe de vérification (`Role` = verifier) est la seule à
|
||||
pouvoir marquer `passed`. Chaque changement pousse la liste à l'écran (delta
|
||||
`criteria` + `ts`) par un nouveau champ `deltas` de l'hôte :
|
||||
|
||||
- le journal de la discussion pour un tour ;
|
||||
- celui de la conversation pour une passe de vérification.
|
||||
|
||||
Écart assumé : le Go rangeait les critères sous la discussion **active**
|
||||
au moment de l'appel (`convEnsureActive`). La façade les range sous celle du
|
||||
tour. Elles ne diffèrent que si l'utilisateur change de discussion pendant
|
||||
la génération, et c'est alors la façade qui a raison.
|
||||
|
||||
`see_image` reste au Go. Il redresse et réduit l'image (EXIF, taille) avant
|
||||
de l'encoder, ce qu'un port ne rendrait pas octet pour octet.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_criteria_test.go` tire du Go un journal de 22 appels
|
||||
(`tests/parity_tools/criteria.json`). Chaque étape porte :
|
||||
|
||||
- les arguments, builder ou vérification ;
|
||||
- le résultat ;
|
||||
- les critères rangés ensuite.
|
||||
|
||||
`tests/tools.rs` (`criteria_comme_le_go`) le rejoue contre le port. Il
|
||||
vérifie aussi :
|
||||
|
||||
- le rangement scellé ;
|
||||
- la liste poussée à l'écran à chaque changement, et seulement alors.
|
||||
|
||||
`tests/lot6.rs` (`contrat_verifie_par_la_facade`) fait tourner un vrai
|
||||
contrat. La passe de vérification marque le critère `passed` dans la
|
||||
façade : rien ne passe par le Go, le critère est rangé scellé, l'écran
|
||||
reçoit la liste.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lot 39 — Anneau de télémétrie tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Chaque complétion qui passe par le moteur est rangée dans un anneau
|
||||
(`perf_log.go`). L'anneau :
|
||||
|
||||
- calcule la perte de cache d'une complétion contre la précédente de la même
|
||||
discussion et de la même nature ;
|
||||
- nomme ce qui s'est intercalé (sous-agent, client externe…) ;
|
||||
- signale une perte au-delà du seuil du preset (stats finales) ;
|
||||
- nourrit `/api/perf/summary` et les poids de l'optimiseur.
|
||||
|
||||
Depuis le lot 29, la façade envoyait chaque complétion de ses tours au Go
|
||||
(`/internal/v1/turn/perf`). C'était un aller-retour par complétion pour un
|
||||
anneau tenu ailleurs.
|
||||
|
||||
## Le lot
|
||||
|
||||
**Rust** (`src/perfring.rs`, port de `perf_log.go`) :
|
||||
|
||||
- l'anneau : 5000 entrées, 128 discussions suivies, 500 entrées remontées au
|
||||
plus pour nommer ce qui s'est intercalé ;
|
||||
- la perte : seulement si la complétion prolonge strictement la précédente
|
||||
(empreintes cumulées des messages), cache connu, complétion allée au bout ;
|
||||
- la ligne `[perf]` (LOKI_PERF_LOG) ;
|
||||
- le seuil d'alerte, dans le même ordre de priorité qu'au lancement :
|
||||
- `-cms` d'EXTRA_ARGS ;
|
||||
- `LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT` ;
|
||||
- `CKPT_MIN_STEP` ;
|
||||
- 2048 sur un modèle hybride ;
|
||||
- plus le micro-batch ;
|
||||
- le résumé de `/api/perf/summary` (par nature, par profondeur, brouillon,
|
||||
dernier verdict de la sonde de gabarit).
|
||||
|
||||
`ChatShared` porte l'anneau. Les tours, les passes de vérification, les
|
||||
résumés de compaction et le préchauffage de la façade y rangent leurs
|
||||
complétions directement. Les marques (perte, ce qui s'est intercalé,
|
||||
alerte) partent sur les stats finales comme avant.
|
||||
|
||||
La façade sert :
|
||||
|
||||
- `POST /internal/facade/v1/perf/record` : une complétion faite par le Go
|
||||
(sous-agent, tâche IA, bench, compaction et préchauffage de ses tours,
|
||||
client externe de `/v1`), rendue telle que rangée ;
|
||||
- `POST /internal/facade/v1/perf/snapshot` : l'anneau, pour les poids de
|
||||
l'optimiseur ;
|
||||
- `GET /api/perf/summary`.
|
||||
|
||||
**Go** (`facade_perf.go`) : quand la façade tient la conversation,
|
||||
`perfRecord` range chez elle, et `perfSnapshot` relit son anneau
|
||||
(optimiseur, contexte de `/internal/v1/tune/context`). `/api/perf/summary`
|
||||
lui est remis (`facadeChatRoutes`). Façade injoignable : l'anneau du Go
|
||||
prend le relais. Sans façade, ou la conversation rendue au Go
|
||||
(`LOKI_CHAT=go`), rien ne change.
|
||||
|
||||
La route `/internal/v1/turn/perf` est retirée : plus personne ne l'appelle.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_perf_test.go` tire du Go un journal
|
||||
(`tests/parity_perf/perf.json`). Quatorze complétions y sont rangées, avec
|
||||
les empreintes de leurs messages ; elles couvrent :
|
||||
|
||||
- prolongement ;
|
||||
- perte après un sous-agent et un client externe ;
|
||||
- cache inconnu ;
|
||||
- divergence ;
|
||||
- autre discussion ;
|
||||
- complétion coupée ;
|
||||
- compaction, bench, vérification.
|
||||
|
||||
Pour chaque complétion, le journal garde ce qui est rangé, la ligne
|
||||
`[perf]` et l'alerte. Il porte aussi le résumé, le résumé vide, et onze
|
||||
seuils d'alerte. `src/perfring/tests.rs` le rejoue, avec l'anneau borné et
|
||||
l'heure posée.
|
||||
|
||||
`tests/lot6.rs` :
|
||||
|
||||
- `cache_perdu_mesure_par_la_facade` : deux tours de la même discussion ;
|
||||
la perte est mesurée par la façade et signalée dans les stats finales ;
|
||||
- `anneau_de_la_facade_pour_le_go` : ce que le Go confie (perte après un
|
||||
client externe), ce qu'il relit, le jeton exigé, `/api/perf/summary`
|
||||
servi par la façade ;
|
||||
- les tests du lot 29 lisent désormais l'anneau de la façade.
|
||||
|
||||
`internal/loki/facade_perf_test.go` (`TestFacadePerfAnneau`) :
|
||||
|
||||
- complétion confiée à la façade ;
|
||||
- anneau relu ;
|
||||
- repli sur l'anneau du Go quand la façade est en panne ;
|
||||
- route remise.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Lot 40 — Outils mémoire de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 13, la façade tient la mémoire quand `LOKI_MEM=rust` :
|
||||
|
||||
- pages ;
|
||||
- index `MEMORY.md` ;
|
||||
- trackers ;
|
||||
- mode mémoire.
|
||||
|
||||
Les outils par lesquels le modèle s'en sert partaient pourtant au Go
|
||||
(`/internal/v1/tool/exec`) :
|
||||
|
||||
- `mem_search`, `mem_read`, `mem_add`, `mem_edit` ;
|
||||
- `tracker`.
|
||||
|
||||
Deux processus écrivaient donc les mêmes pages et le même index.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/memory/tools.rs` porte les outils (`chat_memory.go`, `tracker.go`),
|
||||
mêmes résultats au caractère près. Il réutilise les lectures et écritures des
|
||||
routes de la mémoire :
|
||||
|
||||
- pages scellées selon le chiffrement ;
|
||||
- `.bak` de la version précédente ;
|
||||
- écriture vérifiée ;
|
||||
- index tenu par le code.
|
||||
|
||||
Chaque outil :
|
||||
|
||||
- **`mem_search`** : classement par couverture des termes puis par
|
||||
pertinence (rareté IDF, fréquence amortie, bonus du nom et du titre) ;
|
||||
extrait coupé à l'octet comme le Go (U+FFFD par octet d'un caractère
|
||||
coupé) ; limite 8 par défaut, 30 au plus ;
|
||||
- **`mem_read`** : lignes numérotées, plage, 500 au plus ;
|
||||
- **`mem_add`** : jamais par-dessus une page existante, index tenu (seed au
|
||||
nom du projet), diff d'ajout ;
|
||||
- **`mem_edit`** : `old` unique, édition déjà en place reconnue, diff ;
|
||||
- **`tracker`** :
|
||||
- vue d'ensemble ;
|
||||
- squelette (années, année en cours par mois) ;
|
||||
- une année par mois ;
|
||||
- points (60 plus récents au plus) ;
|
||||
- ajout, modification, suppression d'un point (jamais d'un tracker
|
||||
entier) ;
|
||||
- arguments d'un autre type rendus comme `str` du Go.
|
||||
|
||||
`tracker_add` prend désormais l'heure en paramètre (`tracker_add_at`).
|
||||
|
||||
`ChatShared.memory` porte la même mémoire que les routes. `GoTurnHost::exec_tool`
|
||||
y exécute les outils quand la façade la tient ; sinon l'appel part au Go
|
||||
comme avant.
|
||||
|
||||
Côté Go, l'heure des trackers (« maintenant », année en cours) passe par
|
||||
`trackerNow`, que le journal fixe ; rien ne change en service.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_mem_tools_test.go` tire du Go un journal de 57 appels
|
||||
(`tests/parity_mem/outils.json`), en fuseau UTC et à heure fixée. Chaque
|
||||
étape porte :
|
||||
|
||||
- les arguments ;
|
||||
- le libellé du tour ;
|
||||
- le résultat, le diff ;
|
||||
- les fichiers de la mémoire du projet (`.bak` et index compris) ;
|
||||
- les trackers en base.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- recherche : couverture, rareté, requête vide, limites, extrait coupé ;
|
||||
- lecture : plage, au-delà, page absente, nom invalide ;
|
||||
- ajout : existant, nom invalide, majuscules ;
|
||||
- modification : rejouée, déjà en place, absente, en double, vide ;
|
||||
- trackers : chaque vue, dates illisibles, nom en nombre, identifiants
|
||||
inconnus.
|
||||
|
||||
`tests/memory.rs` (`outils_memoire_comme_le_go`) le rejoue contre le port.
|
||||
|
||||
`tests/lot6.rs` (`outil_memoire_par_la_facade`) fait tourner un tour de la
|
||||
façade qui crée une page. Il vérifie :
|
||||
|
||||
- rien ne passe par le Go ;
|
||||
- la page est rangée dans le projet actif, l'index tenu ;
|
||||
- le résultat est celui que le modèle reçoit.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Lot 41 — Compilation de llama.cpp depuis les sources dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 22, la façade tient les backends llama.cpp quand
|
||||
`LOKI_BACKENDS=rust` :
|
||||
|
||||
- statut, vérification git ;
|
||||
- binaires précompilés ;
|
||||
- bascule fast/opt ;
|
||||
- backends custom ;
|
||||
- le job des moteurs.
|
||||
|
||||
La compilation depuis les sources restait au Go. Il en rapportait chaque
|
||||
étape au job de la façade par l'API interne
|
||||
(`/internal/facade/v1/build-job/*`). Elle couvre trois routes :
|
||||
|
||||
- `POST /api/llamacpp/install` `{force}` ;
|
||||
- `POST /api/llamacpp/update` `{clean}` ;
|
||||
- `POST /api/llamacpp/install-custom` `{repo, name, ref}`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/backends/build.rs` porte la compilation sous Linux et macOS. Sources Go :
|
||||
|
||||
- `backend_build.go` : plan, CMake, étapes suivies ;
|
||||
- `lcRunInstall`, `lcRunUpdate`, `lcBuildAndSwitch`, `lcAppendLogTail`
|
||||
(`web_llamacpp.go`) ;
|
||||
- `installCustomBackend`, `deriveBackendName`, `looksLikeGitURL`
|
||||
(`backend_llamacpp.go`) ;
|
||||
- `autoInstallTool`, `ensureCompiler` (`sys_platform_unix.go`).
|
||||
|
||||
Le port couvre :
|
||||
|
||||
- **le plan complet** (`detectBuildPlan`) :
|
||||
- drapeaux Release, natif, UI de llama-server coupée ;
|
||||
- Metal sous macOS ;
|
||||
- CUDA quand nvcc et un GPU NVIDIA sont là : F16, racine du toolkit et
|
||||
compilateur explicites hors `/usr`, architectures lues par
|
||||
`nvidia-smi` ;
|
||||
- HIP, puis Vulkan ;
|
||||
- **CMake** :
|
||||
- `build/` configuré ailleurs (dépôt déplacé) ou `clean` : mis de côté en
|
||||
`build.old` ;
|
||||
- nvcc dans le PATH et `CUDACXX` ;
|
||||
- `configure.log` et `build.log` dans le dépôt ;
|
||||
- erreurs « configuration CMake échouée » et « compilation échouée » ;
|
||||
- **les étapes suivies** :
|
||||
- sorties standard et d'erreur dans un même tube, ordre gardé ;
|
||||
- git : découpage du `sinkWriter` (barre qui se redessine montrée toutes
|
||||
les 10 s, reste sans fin de ligne perdu) ;
|
||||
- CMake : découpage du `bufio.Scanner` (dernier morceau rendu, ligne de
|
||||
plus de 1 Mio qui arrête la lecture) ;
|
||||
- octets invalides en U+FFFD un à un ;
|
||||
- erreurs au format d'`exec` (`exit status N`, `signal: killed`,
|
||||
programme introuvable) ;
|
||||
- **les outils** :
|
||||
- git et cmake, puis cc, c++, make ;
|
||||
- un manquant est installé au mieux par apt-get, dnf, pacman ou brew
|
||||
(sudo hors root) ;
|
||||
- sinon le message du Go ;
|
||||
- **les jobs** :
|
||||
- installation : clone, compilation propre, BIN ; dépôt présent : bascule
|
||||
en mise à jour ; `force` : supprimé puis recloné ;
|
||||
- mise à jour : fetch, pull au besoin, commits avant et après ;
|
||||
« déjà à jour » sans recompiler ; service arrêté le temps du build et
|
||||
relancé même en échec ;
|
||||
- échec du build : fin des deux journaux (30 lignes) remontée dans le job ;
|
||||
- backend custom : URL vérifiée, nom tiré du dépôt (propriétaire ajouté à
|
||||
un fork nommé llama.cpp) ou assaini, clone ou mise à jour, ref, sans
|
||||
toucher à BIN.
|
||||
|
||||
Les routes répondent comme le Go :
|
||||
|
||||
- moteur fourni par l'image : 409 et le message du Go ;
|
||||
- custom sans URL : 400 « URL du dépôt requise » ;
|
||||
- optimisation en cours : 409 ;
|
||||
- job déjà lancé : 409 ;
|
||||
- sinon 200 `{"ok":true}` et le job tourne.
|
||||
|
||||
Le service moteur est celui de la façade si elle le pilote, sinon celui du
|
||||
Go (`/internal/v1/service/action`), comme pour les binaires précompilés.
|
||||
|
||||
Côté Go, `facadeBackendsRoutes` gagne les trois routes hors Windows. Sous
|
||||
Windows, la compilation reste au Go, qui rapporte toujours ses étapes à la
|
||||
façade :
|
||||
|
||||
- générateur Visual Studio ;
|
||||
- intégration CUDA de MSBuild ;
|
||||
- `CUDA_PATH`.
|
||||
|
||||
Les messages du terminal du Go (outils manquants, dépendance connue absente
|
||||
après un configure en échec) vont à la sortie d'erreur de la façade.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_build_test.go` (Linux) tire du Go un journal de 15
|
||||
étapes (`tests/parity_backends/build.json`). De faux git, cmake et
|
||||
compilateur, rangés dans le journal, sont seuls sur le PATH. Chaque étape
|
||||
porte :
|
||||
|
||||
- la vue du job (lignes, phase, erreur, commits, fichiers compilés) ;
|
||||
- BIN ;
|
||||
- les dépôts qui ont un `build.old`.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- mise à jour sans dépôt ;
|
||||
- installation, puis installation sur un dépôt présent et installation
|
||||
forcée ;
|
||||
- déjà à jour, nouveau commit, recompilation propre ;
|
||||
- build en échec (fin des journaux) ;
|
||||
- cmake absent, compilateur absent ;
|
||||
- clone en échec ;
|
||||
- custom : URL invalide, nouveau fork, nom assaini sur un ref, dossier
|
||||
existant sur un ref (URL `git@`).
|
||||
|
||||
`tests/backends.rs` (`compilation_comme_le_go`) rejoue le journal par la
|
||||
façade : mêmes faux outils dans le PATH des sondes, POST de la route, job
|
||||
suivi jusqu'au bout. `compilation_refusee` vérifie les refus des routes.
|
||||
|
||||
Les tests unitaires de `build.rs` couvrent :
|
||||
|
||||
- la barre de progression (`TestSinkWriterProgression`) ;
|
||||
- le scanner (fin `\r\n`, dernier morceau, octets invalides, ligne
|
||||
démesurée) ;
|
||||
- la racine du toolkit CUDA (`TestCudaToolkitRoot`) ;
|
||||
- le cache CMake déplacé ;
|
||||
- noms et URL des backends custom ;
|
||||
- le plan par machine (CPU, nvcc du PATH, toolkit versionné,
|
||||
architectures, HIP, Vulkan) ;
|
||||
- les erreurs des commandes (code de sortie, signal, programme absent,
|
||||
journal écrit).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Lot 42 — Outils web de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La façade tient la conversation par défaut. Les outils web de ses tours
|
||||
partaient pourtant au Go (`/internal/v1/tool/exec`) :
|
||||
|
||||
- `web_search` ;
|
||||
- `web_images` ;
|
||||
- `web_open`, `web_read`, `web_grep`.
|
||||
|
||||
Le cache des pages (10 min) était donc au Go : `web_open` puis `web_read`
|
||||
faisaient chacun un aller-retour.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/webtools.rs` porte les outils. Sources Go :
|
||||
|
||||
- `chat_internet_tools.go` : les quatre outils de lecture et de recherche ;
|
||||
- `chat_internet.go` : client Crawl4AI, cache, lecture des résultats
|
||||
DuckDuckGo ;
|
||||
- `chat_web_images.go` : recherche d'images.
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **le cache** :
|
||||
- clé : l'URL, suivie de l'empreinte MD5 des options qui changent la page
|
||||
(actions, attente, bandeaux), à l'échappement HTML près du Go ;
|
||||
- `web_read` et `web_grep` lisent l'ouverture la plus récente d'une URL,
|
||||
toutes options confondues ;
|
||||
- 10 minutes ;
|
||||
- **Crawl4AI** :
|
||||
- corps envoyé au Go près (clés triées, échappement HTML) ;
|
||||
- clé d'accès en `Bearer` ;
|
||||
- réponse `{results:[…]}` ou résultat seul, lue comme `json.Unmarshal`
|
||||
(types faux, `null`, casse des clés) ;
|
||||
- markdown en chaîne ou `{raw_markdown, fit_markdown}` ;
|
||||
- erreurs : HTTP (fin du corps), échec du crawl, réponse vide, serveur
|
||||
injoignable (texte de l'erreur du client Go) ;
|
||||
- **les lignes** : `\r\n`, blancs de fin, vides et doublons consécutifs
|
||||
réduits ; plan des titres ;
|
||||
- **la recherche par Crawl4AI** : titres, liens `uddg` décodés deux fois
|
||||
comme le Go, entités HTML, extrait tiré des liens qui suivent, doublons et
|
||||
liens DuckDuckGo écartés, défi anti-bot ;
|
||||
- **les images** :
|
||||
- jeton `vqd` de la page, puis `i.js` ;
|
||||
- mêmes en-têtes ;
|
||||
- cookies gardés par hôte, comme le jar du client Go ;
|
||||
- réponse lue comme `json.Unmarshal` (erreurs de syntaxe et de type au
|
||||
texte du Go) ;
|
||||
- **`web_grep`** :
|
||||
- regex du Go (`(?i)`, classes ASCII) ;
|
||||
- erreurs au texte de `regexp` pour les fautes courantes ;
|
||||
- **le plafond** de 8000 caractères ;
|
||||
- **les arguments** : nombres tronqués comme `int(float64)` du Go.
|
||||
|
||||
Le moteur intégré lit le HTML avec go-readability, html-to-markdown et le
|
||||
parseur HTML5 de x/net : rien en Rust ne les reproduit au caractère près.
|
||||
Ces deux étapes restent au Go, derrière deux appels sans état :
|
||||
|
||||
- `POST /internal/v1/web/fetch` `{url}` → `{markdown}` ou `{error}`
|
||||
(`goFetchMarkdown`) ;
|
||||
- `POST /internal/v1/web/search` `{query, limit}` → `{results}` ou `{error}`
|
||||
(`goSearch`).
|
||||
|
||||
`ChatShared.web` porte les outils ; `GoTurnHost::exec_tool` les exécute
|
||||
avant tout le reste. `web_screenshot` reste au Go (navigateur piloté).
|
||||
|
||||
Le traducteur des regex du Go (`go_regex`) refuse désormais :
|
||||
|
||||
- la répétition d'une répétition (`a**`, `a*??`, `a{2}*`) ;
|
||||
- un compte au-delà de 1000 ou inversé.
|
||||
|
||||
Un `{` qui n'ouvre pas de répétition y devient littéral, comme au Go. Les
|
||||
motifs `retry_patterns` en profitent.
|
||||
|
||||
Côté Go, pour le journal :
|
||||
|
||||
- `webNow` : l'horloge du cache ;
|
||||
- `ddgImagesBase` et `ddgHTMLBase` : les adresses de DuckDuckGo.
|
||||
|
||||
## Écart connu
|
||||
|
||||
reqwest pose `Accept: */*` sur une requête qui n'en a pas ; le client Go
|
||||
n'en pose aucun. Seule la page de recherche d'images est concernée, sans
|
||||
effet sur DuckDuckGo.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_web_tools_test.go` tire du Go un journal de 74
|
||||
appels (`tests/parity_web/outils.json`). Chaque appel porte :
|
||||
|
||||
- le résultat ;
|
||||
- les requêtes reçues par le faux Crawl4AI (méthode, clé, type, corps) ;
|
||||
- les requêtes reçues par le faux DuckDuckGo (chemin, en-têtes, cookies) ;
|
||||
- les pages et recherches demandées au moteur intégré.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- recherche : limites, anti-bot, aucun résultat, panne ;
|
||||
- lecture : plages, bornes, au-delà, page jamais ouverte ;
|
||||
- grep : contexte fusionné, plafond, sans résultat, classes ASCII, sept
|
||||
regex invalides ;
|
||||
- cache : réouverture, forcée, options, la plus récente, péremption ;
|
||||
- GitHub ;
|
||||
- les réponses de Crawl4AI ;
|
||||
- serveur injoignable, sans serveur, sans clé ;
|
||||
- moteur intégré : ouverture, texte brut, page absente, recherche ;
|
||||
- images : doublons, cookie gardé, jeton absent, pannes, réponses
|
||||
illisibles ou mal typées, `null`.
|
||||
|
||||
`tests/webtools.rs` le rejoue contre le port. Le faux Go sert ce que le
|
||||
moteur intégré a tiré des pages.
|
||||
|
||||
D'autres tests complètent le journal :
|
||||
|
||||
- `tests/lot6.rs` (`outil_web_par_la_facade`) : un tour de la façade ouvre
|
||||
une page sans passer par `/tool/exec` ;
|
||||
- `TestInternalMoteurWebIntegre` : les deux appels internes du Go ;
|
||||
- tests unitaires : MD5, clés du cache, URL, entités, nombres, erreurs de
|
||||
regex, réponses de Crawl4AI.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 43 — Jobs d'arrière-plan du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le modèle lance un serveur de dev, un watcher ou un build long
|
||||
avec `bash_bg`. Il en suit la sortie avec `bash_tail`, qui sait aussi
|
||||
l'arrêter. Les deux outils partaient au Go (`/internal/v1/tool/exec`), qui
|
||||
gardait les processus. C'étaient les derniers outils shell du tour encore au
|
||||
Go, `bash` étant porté depuis le lot 7.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/tools/jobs.rs` porte `code_jobs.go`.
|
||||
|
||||
**`bash_bg`** :
|
||||
|
||||
- refus avant tout lancement :
|
||||
- commande vide ;
|
||||
- commande dangereuse (même politique que `bash`) ;
|
||||
- optimisation en cours (`/internal/v1/tune/guard`) ;
|
||||
- journal `.loki/jobs/<id>.log` dans le dossier de la discussion (le
|
||||
contexte des outils du Go) ;
|
||||
- numéros `job1`, `job2`… ;
|
||||
- shell de la plateforme, sans le jeton interne dans l'environnement ;
|
||||
- le processus survit au tour ;
|
||||
- code de sortie du Go : -1 pour un processus tué.
|
||||
|
||||
**`bash_tail`** :
|
||||
|
||||
- 40 lignes par défaut, 200 au plus ; un nombre est tronqué comme
|
||||
`int(float64)` ;
|
||||
- état « en cours » ou « terminé (exit N) » et durée arrondie à la
|
||||
seconde ;
|
||||
- sortie vide signalée ;
|
||||
- fin gardée en caractères ;
|
||||
- octets invalides en U+FFFD un à un ;
|
||||
- `kill:true` arrête le job et relit aussitôt, comme le Go : l'état peut
|
||||
encore dire « en cours ».
|
||||
|
||||
Les erreurs d'écriture ont le texte du Go. `paths::go_mkdir_all` reproduit
|
||||
`os.MkdirAll` : l'erreur nomme l'élément du chemin qui a échoué.
|
||||
|
||||
`ChatShared.jobs` (les jobs du processus) sert les tours :
|
||||
`GoTurnHost::exec_tool` exécute les deux outils. Sans contexte d'outils,
|
||||
`bash_bg` passe au Go comme avant.
|
||||
|
||||
Les jobs d'une discussion supprimée s'arrêtent avec elle
|
||||
(`GoSessionHooks::dropped`, `stopConvJobs`).
|
||||
|
||||
L'optimiseur refuse de mesurer tant qu'un job tourne. Côté Go,
|
||||
`bgJobsRunning` ajoute ceux de la façade quand elle tient la conversation
|
||||
(`POST /internal/facade/v1/bgjobs` → `{running}`) ; façade injoignable :
|
||||
les siens seulement.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_jobs_test.go` (Linux) tire du Go un journal de 24
|
||||
étapes (`tests/parity_jobs/jobs.json`). Chaque étape porte le résultat (pid
|
||||
et durées masqués) et les journaux présents. Il couvre :
|
||||
|
||||
- commande vide, refusée ;
|
||||
- sortie des deux flux, lignes demandées (nulles, en texte, tronquées) ;
|
||||
- arrêt d'un job fini ;
|
||||
- job inconnu, sans id ;
|
||||
- sortie en erreur sans rien d'écrit ;
|
||||
- job long, en cours, arrêté (état relu masqué), puis fini à -1 ;
|
||||
- quarante lignes par défaut, deux cents au plus ;
|
||||
- sortie démesurée en caractères multi-octets ;
|
||||
- octets invalides.
|
||||
|
||||
`tests/jobs.rs` le rejoue contre le port.
|
||||
|
||||
D'autres tests complètent le journal :
|
||||
|
||||
- `tests/lot6.rs` (`job_arriere_plan_par_la_facade`) : un tour de la façade
|
||||
lance un job dans le dossier de la discussion sans passer par le Go ;
|
||||
supprimer une autre discussion n'y touche pas, la sienne l'arrête ;
|
||||
- `anneau_de_la_facade_pour_le_go` : le compte des jobs par l'API interne ;
|
||||
- `TestFacadeJobsArrierePlan` : le Go les ajoute aux siens, façade en panne
|
||||
comprise ;
|
||||
- tests unitaires : refus de l'optimiseur, discussion supprimée, dossier
|
||||
impossible.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Lot 44 — Navigateur piloté dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le contrôle du navigateur donne à l'agent un Chromium local, piloté par le
|
||||
protocole DevTools (CDP). L'agent l'utilise ainsi :
|
||||
|
||||
- il ouvre une page et lit les éléments interactifs numérotés ;
|
||||
- il clique, tape, défile par numéro ;
|
||||
- avec la vision, il voit une capture quadrillée et clique en
|
||||
coordonnées.
|
||||
|
||||
La façade tient la conversation par défaut, mais la session Chrome, les
|
||||
outils `browser_*` et l'anneau des aperçus montrés à l'utilisateur étaient
|
||||
au Go (`/internal/v1/tool/exec`).
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/browser/` porte quatre fichiers Go :
|
||||
|
||||
- `computer_cdp.go` : client CDP, lancement, scripts ;
|
||||
- `computer_use.go` : les neuf outils ;
|
||||
- `browser_grid.go` : la grille de la capture ;
|
||||
- `cu_autoshot.go` : les aperçus.
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **le lancement** :
|
||||
- binaire imposé, celui de Playwright ou du système ;
|
||||
- profil temporaire `loki-cu-*`, et ceux de plus d'une heure balayés ;
|
||||
- mêmes arguments (headless sauf `LOKI_CU_HEADFUL`) ;
|
||||
- tué avec la façade (`PR_SET_PDEATHSIG`) ;
|
||||
- port lu dans `DevToolsActivePort`, cible « page » par `/json` ;
|
||||
- viewport fixe 1280×800 ;
|
||||
- script qui garde les nouveaux onglets dans l'onglet courant ;
|
||||
- **la session** :
|
||||
- une seule, lancée à la demande ;
|
||||
- vérifiée par un ping avant chaque outil et relancée si elle ne répond
|
||||
plus ;
|
||||
- commandes multiplexées par numéro, 30 s au plus ;
|
||||
- requêtes réseau suivies pour attendre le calme (600 ms, 6 s au plus)
|
||||
après un chargement ;
|
||||
- **les scripts** injectés, copiés du Go à l'octet près (le journal
|
||||
compare leurs empreintes) :
|
||||
- éléments actionnables et visibles ;
|
||||
- shadow DOM ;
|
||||
- nom d'un champ par son libellé, sans révéler un mot de passe ;
|
||||
- recherche non destructive ;
|
||||
- liste déroulante native réglée par son option ;
|
||||
- **les outils**, mêmes textes :
|
||||
- snapshot dédoublonné ;
|
||||
- indices de défilement ;
|
||||
- erreurs de navigation (`errorText`, page `chrome-error://`,
|
||||
`ERR_ABORTED` bénin) ;
|
||||
- `back` et `forward` ;
|
||||
- touches nommées ;
|
||||
- plafond de 8000 caractères ;
|
||||
- **la capture** (`browser_screenshot`, vision active) :
|
||||
- PNG propre rangé dans le dossier de travail
|
||||
(`capture-AAAAMMJJ-HHMMSS.png`, heure locale) ;
|
||||
- image du modèle quadrillée tous les 100 px, mêmes pixels que le Go ;
|
||||
à 1280×800 elle est sous la taille maximale et part telle quelle ;
|
||||
- relayée au modèle comme `see_image` ;
|
||||
- **les aperçus** :
|
||||
- après une action qui change la page, jamais après une erreur ;
|
||||
- JPEG en mémoire, anneau de huit ;
|
||||
- servis par `/api/chat/cushot`, que le Go remet désormais à la façade.
|
||||
|
||||
`ChatShared.browser` porte le navigateur du processus ;
|
||||
`GoTurnHost::exec_tool` exécute les outils et prend l'aperçu.
|
||||
|
||||
Un seul navigateur : quand la façade tient la conversation, le Go lui passe
|
||||
ses propres appels (`facade_browser.go`) :
|
||||
|
||||
- outils des sous-agents et des tâches IA ;
|
||||
- aperçus ;
|
||||
- fermeture (interrupteur coupé, ligne de commande).
|
||||
|
||||
Ils passent par `POST /internal/facade/v1/browser`, avec trois opérations :
|
||||
`tool`, `shot`, `shutdown`. Si la façade ne répond pas, le navigateur du Go
|
||||
prend le relais. Couper l'interrupteur côté façade (lot 17) ferme les deux.
|
||||
|
||||
## Écarts connus
|
||||
|
||||
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
|
||||
- Une capture au-delà de 1568 px ne serait pas réduite (le viewport fixe
|
||||
rend le cas impossible).
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_browser_test.go` (Linux) tire du Go un journal de 42
|
||||
étapes (`tests/parity_browser/browser.json`) contre un faux Chrome :
|
||||
|
||||
- un script annonce le port d'un faux serveur CDP ;
|
||||
- le faux CDP répond selon le scénario de chaque étape et note chaque
|
||||
commande.
|
||||
|
||||
Chaque étape porte :
|
||||
|
||||
- le résultat ;
|
||||
- les commandes CDP (méthode, paramètres, scripts résumés par leur
|
||||
empreinte) ;
|
||||
- pour une capture, ses dimensions et dix pixels de la grille ;
|
||||
- les captures rangées.
|
||||
|
||||
Les arguments de lancement sont relevés aussi. Le journal couvre :
|
||||
|
||||
- ouverture, snapshot inchangé, réouverture complète ;
|
||||
- snapshots vide, `null`, sans valeur, illisible, exception JS ;
|
||||
- défilements ;
|
||||
- clics par numéro, introuvable, refusé, en coordonnées ;
|
||||
- saisies libre, dans un champ, liste déroulante, option absente, champ
|
||||
introuvable ;
|
||||
- touches ;
|
||||
- recherches ;
|
||||
- navigations en échec, page d'erreur, interruption bénigne, `back`,
|
||||
`forward`, commande refusée ;
|
||||
- captures sans vision, quadrillée, refusée ;
|
||||
- sortie plafonnée.
|
||||
|
||||
`tests/browser.rs` le rejoue contre le port, puis vérifie l'aperçu (JPEG
|
||||
de l'anneau) et la fermeture (profil retiré).
|
||||
|
||||
`vrai_navigateur` (avec `LOKI_CU_SMOKE=1`, comme `TestCDPSmoke`) pilote un
|
||||
vrai Chromium sur une page locale : ouverture, recherche, saisie, liste
|
||||
déroulante, clic qui navigue, `back`, capture 1280×800, aperçu, navigation
|
||||
en échec.
|
||||
|
||||
D'autres tests complètent :
|
||||
|
||||
- `tests/lot6.rs` (`navigateur_par_la_facade`) : un tour de la façade ouvre
|
||||
une page sans passer par le Go, avec aperçu ; l'API du Go et
|
||||
`/api/chat/cushot` par une façade complète ;
|
||||
- `TestFacadeNavigateur` : le Go passe ses outils, captures, aperçus et
|
||||
fermeture à la façade, et se replie sur son navigateur si elle est en
|
||||
panne ;
|
||||
- tests unitaires : outils, nombres au format du Go, arguments, grille,
|
||||
profils, lancement impossible.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Lot 45 — Outils d'image de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Deux outils de l'agent montrent une image au modèle :
|
||||
|
||||
- `see_image` charge un fichier image du disque dans la vision du
|
||||
modèle ;
|
||||
- `web_screenshot` photographie une page rendue par Playwright, la range
|
||||
dans le dossier de la discussion et donne à l'agent la ligne markdown
|
||||
qui la montre à l'utilisateur.
|
||||
|
||||
La façade tient la conversation par défaut, mais ces deux outils passaient
|
||||
encore par le Go (`/internal/v1/tool/exec`), qui renvoyait l'image à
|
||||
relayer dans `vision_img`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/imagetools.rs` porte trois fichiers Go :
|
||||
|
||||
- `chat_vision_tool.go` (`see_image`) ;
|
||||
- `chat_screenshot.go` (`web_screenshot`) ;
|
||||
- `prepareImageForModel` (`web_upload_orient.go`).
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **la préparation de l'image**, la même que pour les pièces jointes :
|
||||
- orientation EXIF d'une JPEG redressée dans les pixels ;
|
||||
- grand côté ramené sous 1568 px (mêmes pixels que le Go) ;
|
||||
- PNG réencodée en PNG, le reste en JPEG qualité 88 ;
|
||||
- rien à faire, ou image que la bibliothèque standard du Go ne décode
|
||||
pas (WebP, BMP, PNG cassée) : les octets d'origine ;
|
||||
- **la sonde du moteur** (`engineSeesImages`) :
|
||||
- preset externe : sa case « vision » ;
|
||||
- sinon `GET /props` du moteur local, authentifié par la clé de
|
||||
complétion, gardé 10 s ;
|
||||
- appelée en dernier, après toutes les vérifications du fichier ;
|
||||
- **`see_image`**, mêmes textes :
|
||||
- vision coupée, poste distant, chemin manquant ;
|
||||
- format non reconnu, fichier introuvable, dossier ;
|
||||
- trop lourde (12 Mo au plus) ;
|
||||
- moteur aveugle, fichier vide ;
|
||||
- chemin relatif au dossier de travail de l'agent, ou absolu ;
|
||||
- **`web_screenshot`**, mêmes textes :
|
||||
- Playwright absent, url manquante ou invalide ;
|
||||
- internet coupé : seule une adresse locale passe (`localhost`,
|
||||
`127.0.0.1`, `::1`, `0.0.0.0`, `*.localhost`, casse comprise comme
|
||||
le Go) ;
|
||||
- mêmes arguments de Playwright (viewport, page entière, attente,
|
||||
90 s au plus) ;
|
||||
- erreur résumée par ses trois dernières lignes non vides ;
|
||||
- aucune image ou image vide refusée ;
|
||||
- capture rangée dans `captures/` du dossier de la discussion, nommée
|
||||
par l'hôte et l'heure locale ;
|
||||
- ménage : 20 captures et 40 Mo au plus, la nouvelle reste ;
|
||||
- relayée au modèle (« Voici la capture demandée. ») seulement si la
|
||||
vision est active et que le moteur voit les images.
|
||||
|
||||
`ChatShared.vision` porte la sonde du processus ; `GoTurnHost::exec_tool`
|
||||
exécute les deux outils :
|
||||
|
||||
- `see_image` prend le dossier de travail et le poste visé du contexte
|
||||
des outils (lot 7) ; contexte indisponible : le Go prend le relais ;
|
||||
- `web_screenshot` range ses captures dans le dossier de la discussion
|
||||
principale, sous le dossier de travail de l'agent, et les montre par
|
||||
`ToolOutcome.image`.
|
||||
|
||||
Rien ne change côté Go : ces outils n'ont pas d'état partagé, et le Go
|
||||
garde les siens pour ses sous-agents et ses tâches IA.
|
||||
|
||||
## Écarts connus
|
||||
|
||||
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_image_tools_test.go` (Linux) tire du Go un journal
|
||||
(`tests/parity_images/images.json`) avec ses fichiers (en base64), un faux
|
||||
moteur (`/props` authentifié, vision réglée par étape, sondes comptées) et
|
||||
un faux Playwright (script qui note ses arguments et produit l'image, rien,
|
||||
un fichier vide ou une erreur).
|
||||
|
||||
Chaque étape porte :
|
||||
|
||||
- le résultat, avec le texte relayé au modèle ;
|
||||
- l'image relayée :
|
||||
- identique à un fichier : son nom ;
|
||||
- réencodée : son type et ses dimensions ;
|
||||
- en PNG : cinq pixels ;
|
||||
- la capture montrée, les arguments de Playwright, le nombre de sondes ;
|
||||
- le dossier des captures.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- `see_image` :
|
||||
- vision coupée ;
|
||||
- chemin vide, pas une image, introuvable, dossier, trop lourde ;
|
||||
- moteur aveugle, fichier vide ;
|
||||
- onze images : PNG, JPEG, GIF, WebP, BMP, PNG cassée, JPEG tournée,
|
||||
grandes images réduites en largeur et en hauteur, sous-dossier,
|
||||
chemin absolu ;
|
||||
- `web_screenshot` :
|
||||
- url manquante ou invalide, internet coupé ;
|
||||
- capture locale sans vision, capture vue par le modèle ;
|
||||
- page entière réduite ;
|
||||
- vision mais moteur aveugle ;
|
||||
- Playwright en échec, image vide, aucune image ;
|
||||
- ménage des captures.
|
||||
|
||||
`tests/imagetools.rs` le rejoue contre le port. D'autres tests
|
||||
complètent :
|
||||
|
||||
- `tests/lot6.rs` (`outils_image_par_la_facade`) : un tour de la façade
|
||||
appelle les deux outils sans passer par le Go ;
|
||||
- tests unitaires : types d'image, noms de capture, adresses locales,
|
||||
dernières lignes, préparation, ménage, refus avant Playwright.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 46 — Outils recall et recall_search dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La compaction résume les vieux tours d'une discussion. Avant de résumer un
|
||||
gros bloc (un code, une page, un diff), elle l'archive verbatim sous un id
|
||||
court (`r7`) que le résumé cite. L'agent a deux outils pour le retrouver :
|
||||
|
||||
- `recall(id)` rend le bloc exact ;
|
||||
- `recall_search(query)` retrouve un bloc et son id par mots-clés, même
|
||||
sorti du résumé courant.
|
||||
|
||||
La façade compacte elle-même et archive par le Go
|
||||
(`/internal/v1/recall/archive`, lot 6) : l'id vient de la séquence de la
|
||||
base, que seul le Go tient. Les deux outils passaient encore par
|
||||
`/internal/v1/tool/exec`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/recall.rs` porte `toolRecall` et `toolRecallSearch`
|
||||
(`chat_recall.go`) :
|
||||
|
||||
- **l'id tolérant** (`recallIDToSeq`) :
|
||||
- espaces, un préfixe `r` puis un préfixe `R`, espaces encore ;
|
||||
- des chiffres seulement : ni signe, ni dépassement de 64 bits ;
|
||||
- un id mal formé ne lit pas l'archive ;
|
||||
- **les mots-clés** (`recallTokens`) :
|
||||
- mise en minuscules rune par rune, comme `strings.ToLower` (`İ`
|
||||
donne `i`, `Σ` donne `σ`) ;
|
||||
- coupés sur tout ce qui n'est ni `a`–`z`, ni `0`–`9`, ni `à`–`ÿ` ;
|
||||
- deux runes au moins ;
|
||||
- **le score** :
|
||||
- un point par terme présent dans le contenu, deux dans le libellé ;
|
||||
- à égalité, le plus récent d'abord ;
|
||||
- 5 résultats par défaut, 20 au plus, limite tronquée comme
|
||||
`int(float64)` ;
|
||||
- **les textes**, mêmes que le Go :
|
||||
- id ou requête manquants ;
|
||||
- bloc introuvable (id cité par `%q`) ;
|
||||
- bloc rappelé verbatim ;
|
||||
- liste des résultats avec un extrait d'une ligne de 160 runes ;
|
||||
- aucun résultat.
|
||||
|
||||
L'archive reste au Go, qui la sert à la façade :
|
||||
|
||||
- `GET /internal/v1/recall/block?seq=N` : le bloc (200), ou 404 s'il est
|
||||
absent ou chiffré sous une mémoire verrouillée ;
|
||||
- `GET /internal/v1/recall/blocks` : tous les blocs lisibles, par séquence
|
||||
croissante.
|
||||
|
||||
`recallGet` et `recallSearch` du Go passent par les mêmes lectures
|
||||
(`recallGetSeq`, `recallAll`).
|
||||
|
||||
`GoTurnHost::exec_tool` exécute les deux outils avant le repli sur le Go.
|
||||
Si le Go ne répond pas, l'appel passe par lui.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_recall_test.go` archive 35 blocs variés :
|
||||
|
||||
- accents et casse Unicode ;
|
||||
- balises et entités ;
|
||||
- bloc vide ;
|
||||
- premières lignes longues ;
|
||||
- vingt-cinq blocs semblables.
|
||||
|
||||
Il tire ensuite du Go un journal de 41 appels
|
||||
(`tests/parity_recall/recall.json`), avec les blocs tels que l'archive les
|
||||
sert :
|
||||
|
||||
- `recall` :
|
||||
- id absent, non texte, blanc ;
|
||||
- variantes de préfixe et d'espaces ;
|
||||
- zéros de tête ;
|
||||
- mal formé, négatif, signe plus, zéro, absent, démesuré ;
|
||||
- guillemets dans l'id ;
|
||||
- `recall_search` :
|
||||
- requête absente, non texte, blanche ;
|
||||
- mots trop courts ;
|
||||
- casse, accents, `İ`, grec hors des mots ;
|
||||
- souligné coupé ;
|
||||
- extraits bornés ;
|
||||
- limites : une, tronquée, négative, par défaut, au plus, en texte
|
||||
ignorée ;
|
||||
- aucun résultat ;
|
||||
- ponctuation.
|
||||
|
||||
`tests/recall.rs` le rejoue : le faux Go sert les mêmes blocs. Il vérifie
|
||||
aussi le repli quand le Go ne répond pas.
|
||||
|
||||
D'autres tests complètent :
|
||||
|
||||
- `tests/lot6.rs` (`recall_par_la_facade`) : un tour de la façade rappelle
|
||||
et cherche sans passer par `/tool/exec` ;
|
||||
- `TestRecallInterneLecture` : les deux lectures du Go (absent, seq
|
||||
illisible, méthode refusée, ordre) ;
|
||||
- tests unitaires : ids, mots-clés, score, extraits, textes.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Lot 47 — Outil subagent du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le builder peut déléguer une question bornée à un rôle en
|
||||
lecture seule :
|
||||
|
||||
- `explorer` cartographie le code ;
|
||||
- `code-reviewer` relit un changement ;
|
||||
- `planner` découpe un travail et pose les critères d'acceptation.
|
||||
|
||||
Le rôle travaille dans son propre contexte : le prompt du rôle, le
|
||||
briefing machine et la tâche, rien de l'historique. Sa trace n'est ni
|
||||
rangée ni affichée. Seul son texte final revient au builder, comme
|
||||
résultat d'outil. Sur un modèle local, à la fenêtre étroite, c'est ce qui
|
||||
évite de payer dix lectures de fichiers dans le fil principal.
|
||||
|
||||
La façade tient le tour du builder et sa passe de vérification, mais
|
||||
`subagent` passait encore par `/internal/v1/tool/exec` : le Go faisait
|
||||
tourner un `runChat` à lui.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/chat/subagent.rs` porte `toolSubagent` (`code_subagent.go`).
|
||||
|
||||
Avant tout appel au moteur, il fait les mêmes refus que le Go, dans le
|
||||
même ordre, avec les mêmes textes :
|
||||
|
||||
- rôle inconnu (`verifier` compris : seule la boucle du contrat le lance) ;
|
||||
- tâche vide ;
|
||||
- appel depuis un tour qui n'est ni le fil principal ni le builder
|
||||
(garde-fou anti-récursion).
|
||||
|
||||
Le contexte du rôle vient du Go
|
||||
(`POST /internal/v1/turn/subagent {role}`), qui rend :
|
||||
|
||||
- le prompt du rôle suivi du briefing machine ;
|
||||
- les outils du rôle ;
|
||||
- ses capacités (agent, Code, mémoire coupée).
|
||||
|
||||
`subagentView` et `subagentFraming` sont partagés avec `toolSubagent`.
|
||||
|
||||
Le tour du rôle tourne ici avec `run_turn`, comme la passe de
|
||||
vérification :
|
||||
|
||||
- l'endpoint, le raisonnement, l'écho, les motifs de relance et l'arrêt
|
||||
sont ceux du tour qui délègue (`SubTurn`) ;
|
||||
- la température est 0,6 (le TEMP du preset l'emporte) ;
|
||||
- la tâche porte le cadrage du rôle :
|
||||
- planner : critères et étapes, sans demander confirmation ;
|
||||
- autres rôles : seulement les trouvailles, `fichier:ligne` ;
|
||||
- c'est un travail annexe (`engineSideJob`) : second slot, puis slot
|
||||
effacé à la fin pour que le builder reparte du cache RAM ;
|
||||
- la télémétrie le compte à part (`subagent`) ;
|
||||
- les critères posés par le planner s'affichent comme ceux du fil.
|
||||
|
||||
Le rapport se construit ainsi :
|
||||
|
||||
- seul le texte écrit après le dernier outil compte ;
|
||||
- il est rogné de ses blancs, puis préfixé du rôle ;
|
||||
- au-delà de 6000 runes, il est tronqué (`…[tronqué]`) ;
|
||||
- vide : « n'a rien répondu » ;
|
||||
- erreur du tour : « sous-agent <rôle> : <erreur> ».
|
||||
|
||||
Le tour du rôle est porté par `SubHost`. Ce `TurnHost` délègue tout au
|
||||
`GoTurnHost` du rôle, sauf un nouvel appel à `subagent`, toujours refusé.
|
||||
Les outils de la façade sont dans `GoTurnHost::exec_tool_base` ; seul le
|
||||
`exec_tool` du tour principal y ajoute `subagent`. Ainsi le type du tour
|
||||
imbriqué ne se contient pas lui-même.
|
||||
|
||||
Le tour du builder et ses relances par la boucle du contrat prêtent leur
|
||||
`SubTurn`. La vérification aussi, mais son rôle la fait refuser.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_subagent_test.go` tire du Go un journal
|
||||
(`tests/parity_subagent/subagent.json`) contre un faux moteur scripté. Les
|
||||
vues des trois rôles sont rangées avec lui.
|
||||
|
||||
Pour chaque tour délégué, le journal garde chaque requête du rôle :
|
||||
|
||||
- système identique à la vue ;
|
||||
- message de la tâche cadrée ;
|
||||
- température ;
|
||||
- outils ;
|
||||
- nombre de messages.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- les refus :
|
||||
- rôle inconnu, `verifier`, rôle absent ;
|
||||
- tâche vide ou absente ;
|
||||
- récursion depuis un rôle ou depuis la vérification ;
|
||||
- les tours :
|
||||
- réponse directe rognée ;
|
||||
- rapport après deux outils, narration écartée ;
|
||||
- texte vide relancé puis remplacé, comme le fait le tour ;
|
||||
- rien après l'outil ;
|
||||
- rapport tronqué ;
|
||||
- cadrage du planner ;
|
||||
- moteur qui refuse.
|
||||
|
||||
`subagent_comme_le_go` (`tests/lot6.rs`) le rejoue :
|
||||
|
||||
- les refus par `precheck` ;
|
||||
- les tours par un tour complet de la façade qui délègue, contre le même
|
||||
script.
|
||||
|
||||
Il vérifie les requêtes du rôle, le rapport rendu au builder et le travail
|
||||
annexe ouvert puis fermé.
|
||||
|
||||
D'autres tests complètent :
|
||||
|
||||
- `TestSubagentVueInterne` : la vue servie par le Go, rôles refusés ;
|
||||
- tests unitaires : refus et leur ordre, rapports, cadrages.
|
||||
@@ -73,11 +73,16 @@ func writeAPIKey(key string) error {
|
||||
}
|
||||
|
||||
// maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4
|
||||
// chars, elide the middle. Empty in → empty out.
|
||||
// chars, elide the middle. Empty in → empty out. Une clé d'un ou deux octets
|
||||
// n'est pas montrée du tout : k[len(k)-2:] paniquait sur un seul octet, et
|
||||
// /api/apikey tombait à chaque lecture tant que la clé restait en place.
|
||||
func maskAPIKey(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if len(k) <= 2 {
|
||||
return "…"
|
||||
}
|
||||
if len(k) <= 13 {
|
||||
return "…" + k[len(k)-2:]
|
||||
}
|
||||
|
||||
@@ -282,10 +282,96 @@ type dlState struct {
|
||||
Part int `json:"part"` // tranche en cours (1-based)
|
||||
Finished bool `json:"finished"`
|
||||
Canceled bool `json:"canceled"`
|
||||
Paused bool `json:"paused"`
|
||||
Err string `json:"error"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
|
||||
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
|
||||
gate *dlGate `json:"-"`
|
||||
}
|
||||
|
||||
// dlGate met un téléchargement en pause (repris d'AJEAN v0.18.1) : les
|
||||
// connexions en cours sont coupées (rien ne reste ouvert vers le CDN pendant la
|
||||
// pause), et chaque morceau attend la reprise puis repart de l'octet où il
|
||||
// s'était arrêté, par une requête de plage.
|
||||
type dlGate struct {
|
||||
mu sync.Mutex
|
||||
paused bool
|
||||
wake chan struct{} // fermé à la reprise
|
||||
attempt map[int]context.CancelFunc // requêtes en cours, coupées par la pause
|
||||
next int
|
||||
}
|
||||
|
||||
func newDlGate() *dlGate { return &dlGate{attempt: map[int]context.CancelFunc{}} }
|
||||
|
||||
func (g *dlGate) setPaused(p bool) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p == g.paused {
|
||||
return
|
||||
}
|
||||
g.paused = p
|
||||
if p {
|
||||
g.wake = make(chan struct{})
|
||||
for _, c := range g.attempt {
|
||||
c()
|
||||
}
|
||||
return
|
||||
}
|
||||
close(g.wake)
|
||||
}
|
||||
|
||||
// wait bloque tant que le téléchargement est en pause. false = annulé entre-temps.
|
||||
func (g *dlGate) wait(ctx context.Context) bool {
|
||||
if g == nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
paused, wake := g.paused, g.wake
|
||||
g.mu.Unlock()
|
||||
if !paused {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
select {
|
||||
case <-wake:
|
||||
return ctx.Err() == nil
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (g *dlGate) isPaused() bool {
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.paused
|
||||
}
|
||||
|
||||
// begin ouvre une requête que la pause pourra couper ; la fonction rendue la referme.
|
||||
func (g *dlGate) begin(ctx context.Context) (context.Context, func()) {
|
||||
if g == nil {
|
||||
return ctx, func() {}
|
||||
}
|
||||
actx, cancel := context.WithCancel(ctx)
|
||||
g.mu.Lock()
|
||||
id := g.next
|
||||
g.next++
|
||||
g.attempt[id] = cancel
|
||||
if g.paused {
|
||||
cancel() // pause tombée entre wait et begin
|
||||
}
|
||||
g.mu.Unlock()
|
||||
return actx, func() {
|
||||
g.mu.Lock()
|
||||
delete(g.attempt, id)
|
||||
g.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -429,7 +515,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
st := &dlState{
|
||||
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, gate: newDlGate(),
|
||||
}
|
||||
dlDownloads[name] = st
|
||||
dlMu.Unlock()
|
||||
@@ -759,7 +845,9 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
|
||||
dt := now.Sub(lastAt).Seconds()
|
||||
dlMu.Lock()
|
||||
st.Done = cur
|
||||
if dt > 0 {
|
||||
if st.Paused {
|
||||
st.Speed = 0
|
||||
} else if dt > 0 {
|
||||
inst := int64(float64(cur-last) / dt)
|
||||
if st.Speed == 0 {
|
||||
st.Speed = inst
|
||||
@@ -827,7 +915,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
|
||||
if err := dlFetch(ctx, st.gate, f, dlURL, total, conns, done); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -845,9 +933,9 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
|
||||
// dlFetch writes the whole body into f, either as one stream or as `conns`
|
||||
// parallel byte ranges. done is incremented atomically as bytes land on disk.
|
||||
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
if conns <= 1 {
|
||||
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
|
||||
return dlChunk(ctx, g, f, dlURL, 0, total-1, total <= 0, done)
|
||||
}
|
||||
size := total / int64(conns)
|
||||
var wg sync.WaitGroup
|
||||
@@ -861,7 +949,7 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
wg.Add(1)
|
||||
go func(i int, start, end int64) {
|
||||
defer wg.Done()
|
||||
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
|
||||
errs[i] = dlChunk(ctx, g, f, dlURL, start, end, false, done)
|
||||
}(i, start, end)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -876,11 +964,15 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
// dlChunk downloads [start,end] into f at the right offset, retrying from where
|
||||
// it stopped if the connection drops mid-chunk. With whole=true it streams the
|
||||
// entire body sequentially (server without range support, unknown size).
|
||||
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
const attempts = 4
|
||||
pos := start
|
||||
var lastErr error
|
||||
for try := 0; try < attempts; try++ {
|
||||
// Une pause n'est pas un échec : on attend la reprise sans consommer d'essai.
|
||||
if !g.wait(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err // cancelled: never retry
|
||||
}
|
||||
@@ -900,18 +992,26 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
} else if pos > start {
|
||||
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
|
||||
}
|
||||
req, err := dlRequest(ctx, dlURL, rng)
|
||||
actx, release := g.begin(ctx)
|
||||
req, err := dlRequest(actx, dlURL, rng)
|
||||
if err != nil {
|
||||
release()
|
||||
return err
|
||||
}
|
||||
resp, err := dlClient.Do(req)
|
||||
if err != nil {
|
||||
release()
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try--
|
||||
continue
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||
err := dlSourceError(resp, dlURL)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == 200 && pos > start {
|
||||
@@ -921,10 +1021,15 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
}
|
||||
n, cerr := dlCopy(f, resp.Body, pos, done)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
pos += n
|
||||
if cerr == nil {
|
||||
return nil
|
||||
}
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try-- // coupé par la pause : la reprise repart de pos
|
||||
continue
|
||||
}
|
||||
lastErr = cerr
|
||||
}
|
||||
return lastErr
|
||||
@@ -981,6 +1086,35 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleModelDownloadPause met en pause ({"pause":true}) ou reprend
|
||||
// ({"pause":false}) un téléchargement en cours. Le fichier partiel est gardé.
|
||||
func handleModelDownloadPause(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
Pause bool `json:"pause"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := filepath.Base(strings.TrimSpace(req.Filename))
|
||||
dlMu.Lock()
|
||||
st, ok := dlDownloads[name]
|
||||
if !ok || st.Finished {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement en cours pour " + name})
|
||||
return
|
||||
}
|
||||
st.Paused = req.Pause
|
||||
if req.Pause {
|
||||
st.Speed = 0
|
||||
}
|
||||
g := st.gate
|
||||
dlMu.Unlock()
|
||||
g.setPaused(req.Pause)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "paused": req.Pause})
|
||||
}
|
||||
|
||||
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
|
||||
// startup. A download killed by a crash or a service restart can't be resumed
|
||||
// (its state lived in memory), so the partial file would otherwise sit there
|
||||
|
||||
@@ -231,3 +231,96 @@ func TestDLProbeSurfacesExplainedError(t *testing.T) {
|
||||
t.Errorf("message %q sans indication sur le jeton", err)
|
||||
}
|
||||
}
|
||||
|
||||
type slowWriter struct{ http.ResponseWriter }
|
||||
|
||||
func (s slowWriter) Write(b []byte) (int, error) {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
return s.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Une pause coupe le flux, garde ce qui est écrit, et la reprise termine un
|
||||
// fichier identique à la source (repris d'AJEAN v0.18.1).
|
||||
func TestRunDownloadPauseResume(t *testing.T) {
|
||||
data := make([]byte, 48<<20)
|
||||
rand.New(rand.NewSource(2)).Read(data)
|
||||
t.Setenv("LOKI_DL_CONNS", "4")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(slowWriter{w}, r, "m.gguf", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "m.gguf")
|
||||
st := &dlState{Filename: "m.gguf", gate: newDlGate()}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
st.gate.setPaused(true)
|
||||
time.Sleep(1500 * time.Millisecond) // le compteur publié se fige
|
||||
dlMu.Lock()
|
||||
a := st.Done
|
||||
dlMu.Unlock()
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
dlMu.Lock()
|
||||
b, fin := st.Done, st.Finished
|
||||
dlMu.Unlock()
|
||||
if fin || a != b || a == 0 {
|
||||
t.Fatalf("pause inopérante : %d puis %d octets, terminé=%v", a, b, fin)
|
||||
}
|
||||
st.gate.setPaused(false)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("pas terminé après la reprise")
|
||||
}
|
||||
if st.Err != "" {
|
||||
t.Fatalf("erreur après reprise : %s", st.Err)
|
||||
}
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("fichier différent de la source après reprise (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// La route : pause puis reprise d'un téléchargement connu, refus pour
|
||||
// l'inconnu et pour un téléchargement terminé.
|
||||
func TestHandleModelDownloadPause(t *testing.T) {
|
||||
dlMu.Lock()
|
||||
saved := dlDownloads
|
||||
dlDownloads = map[string]*dlState{
|
||||
"a.gguf": {Filename: "a.gguf", Speed: 9, gate: newDlGate()},
|
||||
"b.gguf": {Filename: "b.gguf", Finished: true},
|
||||
}
|
||||
dlMu.Unlock()
|
||||
t.Cleanup(func() { dlMu.Lock(); dlDownloads = saved; dlMu.Unlock() })
|
||||
post := func(body string) (int, string) {
|
||||
w := httptest.NewRecorder()
|
||||
handleModelDownloadPause(w, httptest.NewRequest("POST", "/api/models/download/pause", strings.NewReader(body)))
|
||||
return w.Code, strings.TrimSpace(w.Body.String())
|
||||
}
|
||||
if code, out := post(`{"filename":"../a.gguf","pause":true}`); code != 200 || out != `{"ok":true,"paused":true}` {
|
||||
t.Fatalf("pause : %d %s", code, out)
|
||||
}
|
||||
dlMu.Lock()
|
||||
st := dlDownloads["a.gguf"]
|
||||
paused, speed := st.Paused, st.Speed
|
||||
dlMu.Unlock()
|
||||
if !paused || speed != 0 || !st.gate.isPaused() {
|
||||
t.Errorf("état après pause : paused=%v speed=%d porte=%v", paused, speed, st.gate.isPaused())
|
||||
}
|
||||
if code, out := post(`{"filename":"a.gguf","pause":false}`); code != 200 || out != `{"ok":true,"paused":false}` || st.gate.isPaused() {
|
||||
t.Fatalf("reprise : %d %s", code, out)
|
||||
}
|
||||
for _, body := range []string{`{"filename":"b.gguf","pause":true}`, `{"filename":"x.gguf","pause":true}`} {
|
||||
if code, _ := post(body); code != 404 {
|
||||
t.Errorf("%s : %d, attendu 404", body, code)
|
||||
}
|
||||
}
|
||||
if code, _ := post(`[`); code != 400 {
|
||||
t.Errorf("corps illisible : %d", code)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
|
||||
// llamaReleasesAPI : variable pour que les tests la pointent sur un faux GitHub.
|
||||
var llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
|
||||
|
||||
type ghAsset struct {
|
||||
Name string `json:"name"`
|
||||
@@ -41,18 +42,25 @@ func prebuiltDir() string {
|
||||
return filepath.Join(backendsDir(), "llama.cpp-prebuilt")
|
||||
}
|
||||
|
||||
// prebuiltVersionFields : les champs du marqueur VERSION, « tag cudaVer
|
||||
// format ». La version CUDA est vide hors CUDA Windows : « b300 fmt2 » n'a
|
||||
// alors que DEUX champs, et le format est le second.
|
||||
func prebuiltVersionFields() []string {
|
||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
|
||||
// (cudaVer vide hors CUDA Windows).
|
||||
func prebuiltVersion() (tag, cudaVer string) {
|
||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
f := strings.Fields(strings.TrimSpace(string(b)))
|
||||
f := prebuiltVersionFields()
|
||||
if len(f) > 0 {
|
||||
tag = f[0]
|
||||
}
|
||||
if len(f) > 1 {
|
||||
if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") {
|
||||
cudaVer = f[1]
|
||||
}
|
||||
return
|
||||
@@ -64,15 +72,13 @@ func prebuiltVersion() (tag, cudaVer string) {
|
||||
// sera refaite au lieu d'être déclarée « déjà à jour ».
|
||||
const prebuiltFormat = "fmt2"
|
||||
|
||||
// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
|
||||
// prebuiltVersionFormat lit le format, dernier champ du marqueur VERSION (""
|
||||
// si absent). Lu jadis comme 3e champ, il manquait dès que la version CUDA
|
||||
// était vide (Linux, macOS) : chaque vérification réinstallait la release.
|
||||
func prebuiltVersionFormat() string {
|
||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
f := strings.Fields(strings.TrimSpace(string(b)))
|
||||
if len(f) > 2 {
|
||||
return f[2]
|
||||
f := prebuiltVersionFields()
|
||||
if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") {
|
||||
return f[len(f)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+200
-31
@@ -5,11 +5,13 @@ package loki
|
||||
// répartit les experts entre VRAM, RAM et disque pour faire tenir le modèle sur
|
||||
// une carte de joueur.
|
||||
//
|
||||
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.0 ») : le moteur est le paquet
|
||||
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.0 — sources de Strata et
|
||||
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.1 ») : le moteur est le paquet
|
||||
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.1 — sources de Strata et
|
||||
// binaire Linux CUDA 12 compilé et validé par l'auteur d'AJEAN (avec ses
|
||||
// modifications : experts de la carte d'appoint gardés hors RAM). Les deux
|
||||
// fichiers sont vérifiés par SHA-256 avant usage.
|
||||
// modifications : experts de la carte d'appoint gardés hors RAM et, depuis la
|
||||
// 1.1, calculés par elle à la lecture des prompts). Les deux fichiers sont
|
||||
// vérifiés par SHA-256 avant usage. Un modèle installé avec la 1.0 passe à la
|
||||
// 1.1 à son premier lancement (strataMigrate).
|
||||
//
|
||||
// Le choix du moteur se fait par PRESET : ENGINE=strata. Sans la clé (ou
|
||||
// ENGINE=llamacpp), rien ne change, llama-server reste le moteur.
|
||||
@@ -47,22 +49,34 @@ import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Paquet figé (release moe-v1.0 d'AJEAN). Le moteur est compilé en CUDA 12.8,
|
||||
// Paquet figé (release moe-v1.1 d'AJEAN). Le moteur est compilé en CUDA 12.8,
|
||||
// mode portable AVX2, sm_75/80/86/89/120 : RTX 20 à 50. Un moteur installé d'un
|
||||
// autre paquet est remplacé au lancement (strataEnsureEngine).
|
||||
// autre paquet est remplacé au lancement (strataEnsureEngine), une installation
|
||||
// d'une version précédente est reprise (strataMigrate).
|
||||
const (
|
||||
strataVersion = "1.0"
|
||||
strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/"
|
||||
strataVersion = "1.1"
|
||||
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
|
||||
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
|
||||
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
|
||||
strataEngineLocal = "strata-linux-x64-cuda12.zip"
|
||||
)
|
||||
|
||||
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage.
|
||||
// strataReleaseBase : la release figée. Une variable pour que les tests la
|
||||
// pointent sur un faux serveur.
|
||||
var strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/"
|
||||
|
||||
// meminfoPath, strataDiskFree : la RAM et la place libre lues sur la machine,
|
||||
// remplaçables par les tests (journal de parité de Strata).
|
||||
var (
|
||||
meminfoPath = "/proc/meminfo"
|
||||
strataDiskFree = diskFree
|
||||
)
|
||||
|
||||
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage
|
||||
// (celles qu'AJEAN publie dans son backend_moe.go pour moe-v1.1).
|
||||
var strataAssetSHA = map[string]string{
|
||||
strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b",
|
||||
strataEngineAsset: "b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c",
|
||||
strataSrcAsset: "5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115",
|
||||
strataEngineAsset: "8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3",
|
||||
}
|
||||
|
||||
// Valeurs de la clé ENGINE d'un preset.
|
||||
@@ -122,8 +136,14 @@ const strataLowRAMHeadroomGB = 10
|
||||
|
||||
// strataDropHeadroomGB : RAM à laisser à côté des experts verrouillés quand la
|
||||
// carte d'aide garde les siens hors RAM (mesuré par AJEAN : 46 Go de RAM,
|
||||
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres).
|
||||
const strataDropHeadroomGB = 7
|
||||
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres ; le moteur 1.1 prend
|
||||
// ~1 Go de plus que la 1.0).
|
||||
const strataDropHeadroomGB = 8
|
||||
|
||||
// strataDropMaxCtx : contexte maximal en mode hors RAM. Le cache KV au-delà de
|
||||
// la part en VRAM est verrouillé en RAM : à 167 936 jetons, le serveur de test
|
||||
// d'AJEAN tombait à 1,3 Go libres dès la première requête (moteur 1.1).
|
||||
const strataDropMaxCtx = 131072
|
||||
|
||||
// strataHelperExpertsGB : ce que la carte d'aide garde d'experts (sa VRAM moins
|
||||
// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go).
|
||||
@@ -154,7 +174,7 @@ const (
|
||||
// memAvailableGB : la RAM réellement disponible (MemAvailable : libre + cache
|
||||
// récupérable), en Go. Hors Linux ou illisible : la RAM totale.
|
||||
func memAvailableGB() float64 {
|
||||
b, err := os.ReadFile("/proc/meminfo")
|
||||
b, err := os.ReadFile(meminfoPath)
|
||||
if err != nil {
|
||||
return totalRAMGB()
|
||||
}
|
||||
@@ -200,7 +220,9 @@ func strataPackHasExpertsBin(args []string) bool {
|
||||
// Pure : la décision se teste en table.
|
||||
func strataResolveExperts(cfg map[string]string, args []string, availGB, helperGB float64) string {
|
||||
binReady := strataPackHasExpertsBin(args) && strataEngineInstalled() == strataEngineAsset
|
||||
dropReady := binReady && strataHelperOn(cfg) && cfg["STRATA_DROP"] != "0"
|
||||
// (experts de la carte d'aide hors RAM : propre au mode d'aide, jamais en
|
||||
// partage des couches, où la seconde carte ne garde pas d'experts)
|
||||
dropReady := binReady && strataHelperOn(cfg) && !strataLayerSplit(cfg) && cfg["STRATA_DROP"] != "0"
|
||||
arena := strataArenaGB(cfg)
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["STRATA_EXPERTS"])) {
|
||||
case strataExpertsDisk:
|
||||
@@ -286,7 +308,7 @@ func strataDetect() strataEnv {
|
||||
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
|
||||
// strataHome() et pas LokiHome() : /data/strata peut être un disque à part
|
||||
// (SSD dédié monté dans le conteneur), c'est sa place libre qui compte.
|
||||
if f := diskFree(strataHome()); f > 0 {
|
||||
if f := strataDiskFree(strataHome()); f > 0 {
|
||||
env.DiskFreeGB = float64(f) / 1e9
|
||||
}
|
||||
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
|
||||
@@ -484,6 +506,7 @@ type strataDetails struct {
|
||||
// pour les réglages : ce que la machine permet et l'état actuel
|
||||
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
|
||||
HelperOn bool `json:"helper_on"`
|
||||
Split bool `json:"split_layers"` // les deux cartes se partagent les couches
|
||||
Vision bool `json:"vision"`
|
||||
}
|
||||
|
||||
@@ -545,6 +568,7 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
|
||||
if strataHelperOn(pc) {
|
||||
d.HelperOn = true
|
||||
d.HelperGPU = gpuName(h)
|
||||
d.Split = strataLayerSplit(pc)
|
||||
}
|
||||
}
|
||||
d.Vision = pc["STRATA_VISION"] != "0"
|
||||
@@ -860,7 +884,8 @@ func strataFindConfig(quant string) (string, error) {
|
||||
//
|
||||
// - --prefill 16384 : +15 % de lecture sur les longs prompts (40K) ;
|
||||
// - --short-read 150 : les petits ajouts (< 150 jetons) sont lus comme en
|
||||
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides ;
|
||||
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides
|
||||
// (48 avec une carte d'aide, voir strataBuildConfig) ;
|
||||
// - --prompt-cache-root 256 : point de reprise posé à la fin du prompt système
|
||||
// (défaut 2048) : une nouvelle conversation démarre en ~0,3 s au lieu de 4-5 s.
|
||||
var strataTuning = map[string]string{
|
||||
@@ -947,6 +972,16 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
|
||||
}
|
||||
helper := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
|
||||
hasHelper := strataHelperOn(cfg)
|
||||
if strataLayerSplit(cfg) {
|
||||
// Partage des couches : le serveur de Strata le fait de lui-même quand sa
|
||||
// clé « gpu » liste plusieurs cartes (--layer-split auto, réparti selon la
|
||||
// VRAM libre), numérotées comme nvidia-smi. Pas de chemin « aide » : la
|
||||
// réserve de VRAM de l'installeur et l'encodeur d'images restent en place.
|
||||
main, _ := strconv.Atoi(strings.TrimSpace(cfg["STRATA_MAIN_GPU"]))
|
||||
second, _ := strconv.Atoi(helper)
|
||||
out["gpu"] = []any{main, second}
|
||||
hasHelper = false
|
||||
}
|
||||
// Deuxième carte en aide : elle garde et CALCULE sa part des experts (le
|
||||
// chemin « helper » du moteur), la carte principale reste CUDA0. Les cartes
|
||||
// sont posées par CUDA_VISIBLE_DEVICES (serveStrata), PAS par la clé « gpu » :
|
||||
@@ -992,6 +1027,10 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
|
||||
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
|
||||
env["STRATA_STAGER_THREADS"] = "8"
|
||||
env["STRATA_STAGER_RING"] = "32"
|
||||
// sa VRAM libre réservée : 640 Mio (défaut 1024) = ~230 experts de plus
|
||||
// hors RAM, l'encodeur d'images y tourne encore ; le moteur refuse moins
|
||||
// de 512
|
||||
env["STRATA_REMOTE_DROP_RESERVE_MIB"] = "640"
|
||||
case "arena":
|
||||
// experts.bin mappé tel quel (variante mesurée par AJEAN), sans part PCIe :
|
||||
// le mappage n'en donne pas l'adresse GPU.
|
||||
@@ -1007,8 +1046,22 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
env[k] = v
|
||||
}
|
||||
if hasHelper {
|
||||
// Lecture d'un prompt (moteur 1.1) : les experts que la carte d'aide garde
|
||||
// sont calculés par elle au lieu d'être recopiés vers la principale par son
|
||||
// lien PCIe (le chemin passe par la source « helper ») ; le chemin par blocs
|
||||
// coûte alors moins cher, les ajouts courts n'en sont exclus que sous 48
|
||||
// jetons. Mesuré par AJEAN : 100 nouveaux jetons 1,19 -> 0,85 s, 200 :
|
||||
// 1,96 -> 1,12 s.
|
||||
env["STRATA_PREFILL_HELPER_SOURCE"] = "1"
|
||||
args = strataSetArg(args, "--short-read", "48")
|
||||
}
|
||||
out["env"] = env
|
||||
if ctx := strings.TrimSpace(cfg["CTX"]); ctx != "" {
|
||||
// hors RAM, le cache KV verrouillé au-delà de strataDropMaxCtx ne tient plus
|
||||
if n, err := strconv.Atoi(ctx); err == nil && n > strataDropMaxCtx && env["STRATA_REMOTE_DROP"] == "1" {
|
||||
ctx = strconv.Itoa(strataDropMaxCtx)
|
||||
}
|
||||
args = strataSetArg(args, "--max-context", ctx)
|
||||
}
|
||||
// réglages imposés par le preset (sinon : ceux de l'installeur)
|
||||
@@ -1152,6 +1205,16 @@ func strataHelperOn(cfg map[string]string) bool {
|
||||
return h != "" && h != "-1" && cfg["STRATA_HELPER"] != "0"
|
||||
}
|
||||
|
||||
// strataLayerSplit : les deux cartes se partagent les COUCHES du modèle (clé de
|
||||
// preset STRATA_SPLIT=layers) au lieu que la seconde n'aide que pour une part
|
||||
// des experts. Mesuré par un utilisateur d'AJEAN sur deux RTX 3060 identiques
|
||||
// (#121 amont) : la carte d'aide ne travaillait qu'à ~15 %, le partage des
|
||||
// couches donnait +50 % en génération. Sur deux cartes très différentes, le
|
||||
// mode d'aide reste le défaut mesuré : le partage est un choix, jamais imposé.
|
||||
func strataLayerSplit(cfg map[string]string) bool {
|
||||
return strataHelperOn(cfg) && cfg["STRATA_SPLIT"] == "layers"
|
||||
}
|
||||
|
||||
// strataCudaDevices : ordre des cartes vu par le moteur, la principale en CUDA0.
|
||||
func strataCudaDevices(cfg map[string]string) string {
|
||||
main := strings.TrimSpace(cfg["STRATA_MAIN_GPU"])
|
||||
@@ -1174,17 +1237,99 @@ func strataPreflight(cfg map[string]string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("configuration de Strata introuvable : %s — réinstalle le modèle", path)
|
||||
}
|
||||
py := filepath.Join(filepath.Dir(path), ".venv", "bin", "python")
|
||||
py := strataVenvPython(filepath.Dir(path))
|
||||
if _, err := os.Stat(py); err != nil {
|
||||
return fmt.Errorf("environnement Python de Strata absent (%s) : réinstalle le modèle", py)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// strataVenvPython : le Python de l'environnement de Strata, dans ses sources.
|
||||
func strataVenvPython(srcDir string) string { return filepath.Join(srcDir, ".venv", "bin", "python") }
|
||||
|
||||
// strataMigrate : un preset installé par une version précédente du paquet
|
||||
// (STRATA_CONFIG dans strataHome()/ajean-moe-<ancienne>) passe à la version
|
||||
// courante au lancement, sans réinstaller : les modèles et packs (data/)
|
||||
// servent tels quels. Les sources de la version courante sont posées (~340 Mo
|
||||
// avec le moteur), l'environnement Python de l'ancienne est repris par un lien
|
||||
// quand les dépendances n'ont pas changé, le moteur compilé est installé par
|
||||
// strataEnsureEngine, la config de l'installeur est recopiée avec ses chemins
|
||||
// réécrits, et les presets qui la citaient comme la config vivante pointent sur
|
||||
// la nouvelle. En cas d'échec rien n'est réécrit : l'ancienne version continue
|
||||
// de tourner. Repris d'AJEAN (moeMigrate, AJEAN MoE 1.1).
|
||||
func strataMigrate(cfg map[string]string) error {
|
||||
old := strings.TrimSpace(cfg["STRATA_CONFIG"])
|
||||
oldDir := filepath.Dir(old)
|
||||
newDir := strataSrcDir()
|
||||
if old == "" || oldDir == newDir || filepath.Dir(oldDir) != strataHome() || !strings.HasPrefix(filepath.Base(oldDir), "ajean-moe-") {
|
||||
return nil
|
||||
}
|
||||
newPath := filepath.Join(newDir, filepath.Base(old))
|
||||
if !isFile(newPath) {
|
||||
raw, err := os.ReadFile(old)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isFile(filepath.Join(newDir, "setup.py")) {
|
||||
src, err := strataFetch(strataSrcAsset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := extractArchive(src, strataHome()); err != nil {
|
||||
return fmt.Errorf("décompression : %w", err)
|
||||
}
|
||||
}
|
||||
venv := filepath.Join(newDir, ".venv")
|
||||
if _, err := os.Lstat(venv); err != nil {
|
||||
a, _ := os.ReadFile(filepath.Join(oldDir, "requirements.txt"))
|
||||
b, _ := os.ReadFile(filepath.Join(newDir, "requirements.txt"))
|
||||
if len(a) == 0 || string(a) != string(b) || !isFile(strataVenvPython(oldDir)) {
|
||||
return fmt.Errorf("dépendances Python changées : réinstalle le modèle depuis Paramètres → Moteur → Strata")
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(oldDir, ".venv"), venv); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(newDir, "engine-cuda12"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := strataEnsureEngine(); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.ReplaceAll(string(raw), oldDir+string(os.PathSeparator), newDir+string(os.PathSeparator))
|
||||
if err := os.WriteFile(newPath+".new", []byte(body), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(newPath+".new", newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// les presets qui citent l'ancienne config, puis la config vivante (les deux
|
||||
// changent ensemble : l'empreinte du preset actif reste celle de la config)
|
||||
if list, _ := filepath.Glob(filepath.Join(presetsDir(), "*.env")); len(list) > 0 {
|
||||
for _, p := range list {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil || !strings.Contains(string(b), "STRATA_CONFIG="+old+"\n") {
|
||||
continue
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(strings.ReplaceAll(string(b), "STRATA_CONFIG="+old+"\n", "STRATA_CONFIG="+newPath+"\n")), 0o644)
|
||||
}
|
||||
}
|
||||
if err := SetConfigKey("STRATA_CONFIG", newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["STRATA_CONFIG"] = newPath
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] paquet AJEAN MoE %s : installation reprise depuis %s\n", strataVersion, filepath.Base(oldDir))
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveStrata : la branche Strata de `loki serve`. Écrit la config finale à
|
||||
// côté de celle de l'installeur puis remplace le process par le serveur de
|
||||
// Strata (comme pour llama-server : le superviseur suit directement le moteur).
|
||||
func serveStrata(cfg map[string]string) error {
|
||||
if err := strataMigrate(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] passage au paquet AJEAN MoE %s impossible (%v) : la version installée reste utilisée\n", strataVersion, err)
|
||||
}
|
||||
if err := strataPreflight(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1213,7 +1358,7 @@ func serveStrata(cfg map[string]string) error {
|
||||
return err
|
||||
}
|
||||
srcDir := filepath.Dir(path)
|
||||
py := filepath.Join(srcDir, ".venv", "bin", "python")
|
||||
py := strataVenvPython(srcDir)
|
||||
tag := strataConfigTag(path)
|
||||
final["log"] = filepath.Join(strataHome(), "strata-"+tag+".log")
|
||||
out := filepath.Join(strataHome(), "loki-strata-"+tag+".json")
|
||||
@@ -1316,6 +1461,9 @@ type strataSettingsReq struct {
|
||||
Vision bool `json:"vision"`
|
||||
Helper bool `json:"helper"`
|
||||
Experts string `json:"experts"` // auto, ram ou disk ; vide = inchangé
|
||||
// Split : rôle de la seconde carte, « helper » (aide aux experts) ou
|
||||
// « layers » (partage des couches). Vide = inchangé (anciens clients).
|
||||
Split string `json:"split"`
|
||||
}
|
||||
|
||||
// strataApplySettings réécrit les clés du preset (pure, testable).
|
||||
@@ -1330,6 +1478,8 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
return "", fmt.Errorf("MTP hors limites : %d", r.Spec)
|
||||
case r.Experts != "" && r.Experts != strataExpertsAuto && r.Experts != strataExpertsRAM && r.Experts != strataExpertsDisk:
|
||||
return "", fmt.Errorf("placement des experts inconnu : %s", r.Experts)
|
||||
case r.Split != "" && r.Split != "helper" && r.Split != "layers":
|
||||
return "", fmt.Errorf("répartition inconnue : %s", r.Split)
|
||||
}
|
||||
set := map[string]string{
|
||||
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
|
||||
@@ -1340,6 +1490,14 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
set["STRATA_EXPERTS"] = r.Experts
|
||||
keys = append(keys, "STRATA_EXPERTS")
|
||||
}
|
||||
if r.Split != "" {
|
||||
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
|
||||
set["STRATA_SPLIT"] = ""
|
||||
if r.Split == "layers" {
|
||||
set["STRATA_SPLIT"] = "layers"
|
||||
}
|
||||
keys = append(keys, "STRATA_SPLIT")
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
|
||||
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
|
||||
@@ -1349,6 +1507,9 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if set[k] == "" && k == "STRATA_SPLIT" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, k+"="+set[k])
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
@@ -1380,25 +1541,33 @@ func handleStrataSettings(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// « Actif » se décide AVANT l'écriture (comme SavePresetApplying) : c'est
|
||||
// l'ancien contenu qui porte l'empreinte de la configuration courante. Lu
|
||||
// après, un réglage changé faisait passer le preset en service pour
|
||||
// inactif, et rien n'était appliqué.
|
||||
active := false
|
||||
if list, err := ListPresets(); err == nil {
|
||||
for _, p := range list {
|
||||
if p.ID == req.Preset && p.Active {
|
||||
active = true
|
||||
}
|
||||
}
|
||||
}
|
||||
path := filepath.Join(presetsDir(), req.Preset+".env")
|
||||
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
restarted := false
|
||||
if list, err := ListPresets(); err == nil {
|
||||
for _, p := range list {
|
||||
if p.ID == req.Preset && p.Active {
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
if err := SwitchToPreset(path); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
restarted = true
|
||||
}
|
||||
if active {
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
if err := SwitchToPreset(path); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
restarted = true
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "restarted": restarted})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Moteur 1.1 : avec une carte d'aide, la lecture d'un prompt passe par elle
|
||||
// (source « helper ») et le chemin direct ne garde que les ajouts sous 48 jetons.
|
||||
func TestStrataBuildConfigHelperPrompt(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p"}}
|
||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
|
||||
if argAfter(strataArgs(t, out), "--short-read") != "48" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != "1" {
|
||||
t.Errorf("carte d'aide : lecture courte et source d'aide attendues : %v %v", strataArgs(t, out), out["env"])
|
||||
}
|
||||
// carte d'aide coupée ou absente : réglages d'avant
|
||||
for _, cfg := range []map[string]string{{}, {"STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}} {
|
||||
out, _ = strataBuildConfig(base, cfg, "", "ram")
|
||||
if argAfter(strataArgs(t, out), "--short-read") != "150" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != nil {
|
||||
t.Errorf("une carte (%v) : %v %v", cfg, strataArgs(t, out), out["env"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode hors RAM : réserve de VRAM de la carte d'aide abaissée, contexte plafonné.
|
||||
func TestStrataBuildConfigDropReserveAndCtx(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p"}, "env": map[string]any{}}
|
||||
cfg := map[string]string{"STRATA_HELPER_GPU": "0", "CTX": "167936"}
|
||||
out, _ := strataBuildConfig(base, cfg, "", "drop")
|
||||
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != "640" {
|
||||
t.Errorf("réserve de la carte d'aide : %v", out["env"])
|
||||
}
|
||||
if got := argAfter(strataArgs(t, out), "--max-context"); got != "131072" {
|
||||
t.Errorf("contexte hors RAM non plafonné : %s", got)
|
||||
}
|
||||
// hors du mode hors RAM : le contexte demandé
|
||||
for _, mode := range []string{"ram", "arena", "disk"} {
|
||||
out, _ = strataBuildConfig(base, cfg, "", mode)
|
||||
if got := argAfter(strataArgs(t, out), "--max-context"); got != "167936" {
|
||||
t.Errorf("%s : contexte plafonné sans mode hors RAM : %s", mode, got)
|
||||
}
|
||||
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != nil {
|
||||
t.Errorf("%s : réserve du mode hors RAM posée : %v", mode, out["env"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle installé avec le paquet 1.0 passe au paquet courant à son premier
|
||||
// lancement, sans réinstallation.
|
||||
func TestStrataMigrate(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("lien symbolique : Strata ne tourne que sous Linux")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
|
||||
newDir := strataSrcDir()
|
||||
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), filepath.Join(newDir, "engine-cuda12"), presetsDir()} {
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
}
|
||||
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
|
||||
for _, d := range []string{oldDir, newDir} {
|
||||
_ = os.WriteFile(filepath.Join(d, "requirements.txt"), []byte("numpy\n"), 0o644)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
old := filepath.Join(oldDir, "strata-swift-iq3_xxs.json")
|
||||
_ = os.WriteFile(old, []byte(`{"exe": "`+filepath.Join(oldDir, "engine-cuda12", "strata")+`", "args": []}`), 0o644)
|
||||
preset := filepath.Join(presetsDir(), "P.env")
|
||||
_ = os.WriteFile(preset, []byte("ENGINE=strata\nSTRATA_CONFIG="+old+"\nCTX=1\n"), 0o644)
|
||||
other := filepath.Join(presetsDir(), "L.env")
|
||||
_ = os.WriteFile(other, []byte("MODEL=/m.gguf\n"), 0o644)
|
||||
if err := SetConfigKey("STRATA_CONFIG", old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := map[string]string{"ENGINE": "strata", "STRATA_CONFIG": old}
|
||||
if err := strataMigrate(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(newDir, "strata-swift-iq3_xxs.json")
|
||||
if cfg["STRATA_CONFIG"] != want || ReadConfig()["STRATA_CONFIG"] != want {
|
||||
t.Fatalf("config vivante : %q", cfg["STRATA_CONFIG"])
|
||||
}
|
||||
b, _ := os.ReadFile(want)
|
||||
if strings.Contains(string(b), oldDir) || !strings.Contains(string(b), filepath.Join(newDir, "engine-cuda12")) {
|
||||
t.Errorf("chemins non réécrits : %s", b)
|
||||
}
|
||||
if p, _ := os.ReadFile(preset); !strings.Contains(string(p), "STRATA_CONFIG="+want+"\n") {
|
||||
t.Errorf("preset non réécrit : %s", p)
|
||||
}
|
||||
if p, _ := os.ReadFile(other); string(p) != "MODEL=/m.gguf\n" {
|
||||
t.Errorf("preset d'un autre moteur touché : %s", p)
|
||||
}
|
||||
if !isFile(strataVenvPython(newDir)) {
|
||||
t.Error("environnement Python non repris")
|
||||
}
|
||||
// déjà à jour : rien à faire
|
||||
if err := strataMigrate(cfg); err != nil || cfg["STRATA_CONFIG"] != want {
|
||||
t.Errorf("seconde passe : %v %q", err, cfg["STRATA_CONFIG"])
|
||||
}
|
||||
}
|
||||
|
||||
// Dépendances Python changées : rien n'est réécrit, l'ancienne version tourne.
|
||||
func TestStrataMigrateRequirementsChanged(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
|
||||
newDir := strataSrcDir()
|
||||
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), newDir} {
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
}
|
||||
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(oldDir, "requirements.txt"), []byte("numpy\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(newDir, "requirements.txt"), []byte("numpy\ntorch\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
|
||||
old := filepath.Join(oldDir, "strata-iq3_xxs.json")
|
||||
_ = os.WriteFile(old, []byte(`{"args": []}`), 0o644)
|
||||
cfg := map[string]string{"STRATA_CONFIG": old}
|
||||
if err := strataMigrate(cfg); err == nil || !strings.Contains(err.Error(), "dépendances") {
|
||||
t.Fatalf("erreur de dépendances attendue : %v", err)
|
||||
}
|
||||
if cfg["STRATA_CONFIG"] != old || isFile(filepath.Join(newDir, "strata-iq3_xxs.json")) {
|
||||
t.Errorf("config réécrite malgré l'échec : %q", cfg["STRATA_CONFIG"])
|
||||
}
|
||||
// un preset hors de strata/ (copié à la main) n'est pas touché
|
||||
cfg = map[string]string{"STRATA_CONFIG": filepath.Join(home, "ailleurs", "ajean-moe-1.0", "strata-x.json")}
|
||||
if err := strataMigrate(cfg); err != nil {
|
||||
t.Errorf("config hors de strata/ : %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Partage des couches (#121 amont) : la clé « gpu » liste les deux cartes (le
|
||||
// serveur de Strata en déduit --layer-split), et rien du mode d'aide ne subsiste.
|
||||
func TestStrataBuildConfigLayerSplit(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"args": []any{"--pack", "/p", "--vram-reserve-mib", "900"},
|
||||
"vision": map[string]any{"exe": "/v"},
|
||||
}
|
||||
cfg := map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_SPLIT": "layers"}
|
||||
out, err := strataBuildConfig(base, cfg, "", "drop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, ok := out["gpu"].([]any)
|
||||
if !ok || len(g) != 2 || g[0] != 1 || g[1] != 0 {
|
||||
t.Fatalf("gpu = %v, attendu [1 0] (principale d'abord, numéros nvidia-smi)", out["gpu"])
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
|
||||
t.Errorf("options du mode d'aide en partage des couches : %v", a)
|
||||
}
|
||||
if argAfter(a, "--vram-reserve-mib") != "900" {
|
||||
t.Errorf("la réserve de VRAM de l'installeur doit rester : %v", a)
|
||||
}
|
||||
if argAfter(a, "--short-read") != "150" {
|
||||
t.Errorf("lecture des prompts par la carte d'aide en partage des couches : %v", a)
|
||||
}
|
||||
env := out["env"].(map[string]any)
|
||||
if env["STRATA_REMOTE_DROP"] != nil || env["STRATA_PREFILL_HELPER_SOURCE"] != nil {
|
||||
t.Errorf("experts hors RAM ou source d'aide (mode d'aide) en partage des couches : %v", env)
|
||||
}
|
||||
if _, has := out["vision"].(map[string]any)["cuda_device"]; has {
|
||||
t.Errorf("encodeur d'images déplacé sur la seconde carte en partage des couches")
|
||||
}
|
||||
|
||||
// Sans la clé, rien ne change : le mode d'aide reste le défaut.
|
||||
delete(cfg, "STRATA_SPLIT")
|
||||
out, _ = strataBuildConfig(base, cfg, "", "drop")
|
||||
if _, has := out["gpu"]; has {
|
||||
t.Errorf("clé gpu posée en mode d'aide : %v", out["gpu"])
|
||||
}
|
||||
if !strataHasArg(strataArgs(t, out), "--remote-expert-opt") {
|
||||
t.Errorf("mode d'aide perdu sans STRATA_SPLIT")
|
||||
}
|
||||
// Seconde carte coupée : pas de partage même si la clé traîne.
|
||||
cfg["STRATA_SPLIT"], cfg["STRATA_HELPER"] = "layers", "0"
|
||||
if strataLayerSplit(cfg) {
|
||||
t.Errorf("partage des couches avec la seconde carte coupée")
|
||||
}
|
||||
}
|
||||
|
||||
// En partage des couches, la seconde carte ne garde pas d'experts : le mode
|
||||
// hors RAM (qui compte sur elle pour en loger une part) n'est jamais choisi.
|
||||
func TestStrataResolveExpertsLayerSplit(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
pack := filepath.Join(home, "pack")
|
||||
_ = os.MkdirAll(pack, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
|
||||
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
args := []string{"--pack", pack}
|
||||
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
|
||||
t.Fatalf("mode d'aide : %s, attendu drop", got)
|
||||
}
|
||||
cfg["STRATA_SPLIT"] = "layers"
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
|
||||
t.Errorf("partage des couches : %s, attendu arena", got)
|
||||
}
|
||||
cfg["STRATA_EXPERTS"] = "ram"
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "ram" {
|
||||
t.Errorf("partage des couches, experts en RAM : %s, attendu ram", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettingsSplit(t *testing.T) {
|
||||
in := "ENGINE=strata\nSTRATA_HELPER_GPU=0\n"
|
||||
req := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Vision: true, Helper: true, Split: "layers"}
|
||||
out, err := strataApplySettings(in, req)
|
||||
if err != nil || !strings.Contains(out, "STRATA_SPLIT=layers\n") {
|
||||
t.Fatalf("STRATA_SPLIT=layers attendu : %q (%v)", out, err)
|
||||
}
|
||||
req.Split = "helper"
|
||||
if out, _ = strataApplySettings(out, req); strings.Contains(out, "STRATA_SPLIT") {
|
||||
t.Errorf("retour au mode d'aide : la clé doit disparaître : %q", out)
|
||||
}
|
||||
// un ancien client n'envoie pas « split » : la clé est conservée
|
||||
req.Split = ""
|
||||
out, _ = strataApplySettings("STRATA_SPLIT=layers\n", req)
|
||||
if !strings.Contains(out, "STRATA_SPLIT=layers") {
|
||||
t.Errorf("clé effacée par un client qui ne la connaît pas : %q", out)
|
||||
}
|
||||
req.Split = "nimporte"
|
||||
if _, err := strataApplySettings(in, req); err == nil {
|
||||
t.Errorf("répartition inconnue acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDetailsForLayerSplit(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
base := filepath.Join(home, "base.json")
|
||||
_ = os.WriteFile(base, []byte(`{"args":["--pack","/p"],"vision":{"exe":"v"}}`), 0o644)
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_SPLIT=layers\n"
|
||||
_ = os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644)
|
||||
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}, {Index: 1, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}}}
|
||||
d := strataDetailsFor("X", env)
|
||||
if d == nil {
|
||||
t.Fatal("pas de détails")
|
||||
}
|
||||
if !d.Split || !d.HelperOn || d.HelperGPU == "" || d.VisionGPU != d.MainGPU || d.ShortRead != "150" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestStrataDetailsFor(t *testing.T) {
|
||||
}
|
||||
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
|
||||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
|
||||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
|
||||
d.ShortRead != "48" || d.CacheRoot != "256" || d.Prefill != "16384" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,7 +552,7 @@ func runTune(ctx context.Context, opts tuneOpts, tr *tuneTracker) (out *tuneResu
|
||||
|
||||
res := &tuneResult{PresetID: preset.ID, PresetName: preset.Name, BaseFP: configFingerprint(cfg),
|
||||
PresetFP: presetFingerprint([]byte(content)), OptIn: opts.OptIn, At: time.Now().Unix(),
|
||||
Weights: tuneWeightsFrom(perfLog.snapshot())}
|
||||
Weights: tuneWeightsFrom(perfSnapshot())}
|
||||
|
||||
// Référence : la ligne composée telle quelle, la sonde de la machine.
|
||||
tr.setPhase("référence : composition de la ligne de commande")
|
||||
|
||||
@@ -130,7 +130,8 @@ const imageLostMarker = " [image — not kept in the conversation past this poin
|
||||
// (userMessageContent : parties texte + image quand la vision est active) porte
|
||||
// un tableau de parties ; on en recolle les segments `text` pour que l'estimation
|
||||
// de contexte et le transcript de compaction ne partent pas d'un texte vide, et
|
||||
// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans
|
||||
// on marque la présence d'une partie `image_url` (voir imageLostMarker) ou
|
||||
// `input_video` (videoLostMarker, chat_video.go) — sans
|
||||
// quoi elle reste invisible à toute la chaîne de compaction.
|
||||
func msgText(m Message) string {
|
||||
switch v := m.Content.(type) {
|
||||
@@ -146,6 +147,8 @@ func msgText(m Message) string {
|
||||
}
|
||||
case "image_url":
|
||||
b.WriteString(imageLostMarker)
|
||||
case "input_video":
|
||||
b.WriteString(videoLostMarker)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
@@ -163,6 +166,8 @@ func msgText(m Message) string {
|
||||
}
|
||||
case "image_url":
|
||||
b.WriteString(imageLostMarker)
|
||||
case "input_video":
|
||||
b.WriteString(videoLostMarker)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
@@ -242,6 +247,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
|
||||
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
|
||||
}
|
||||
|
||||
// compactRetryDue : une compaction en cours de tour peut-elle être tentée ?
|
||||
// Après un essai qui n'a rien réduit (résumé raté, réduction sous 20 %),
|
||||
// chaque étape suivante relançait un résumé complet voué au même échec : plus
|
||||
// d'une heure de tentatives à vide, une par étape, sur une longue tâche (AJEAN
|
||||
// 68659e73). compactRefusedSkip ne couvre que la discussion principale avec
|
||||
// COMPACT_CONTINUATION ; ce garde-fou vaut pour tout tour, tâches et
|
||||
// sous-agents compris. Jamais à 90 % de la fenêtre ou plus : là, essayer
|
||||
// encore vaut mieux que déborder.
|
||||
func compactRetryDue(retryAt, used int, msgs []Message) bool {
|
||||
if retryAt <= 0 {
|
||||
return true
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
return est >= retryAt || est*10 >= ctxWindow()*9
|
||||
}
|
||||
|
||||
// compactNextRetry : seuil du prochain essai après une compaction en cours de
|
||||
// tour. 0 (aucun) si elle a réduit ; sinon le contexte d'aujourd'hui plus 5 %
|
||||
// de la fenêtre.
|
||||
func compactNextRetry(changed bool, used int, msgs []Message) int {
|
||||
if changed {
|
||||
return 0
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
next := est + ctxWindow()/20
|
||||
fmt.Fprintf(os.Stderr, "[compact] rien réduit en cours de tour : prochain essai à %d jetons (contexte à %d)\n", next, est)
|
||||
return next
|
||||
}
|
||||
|
||||
func compactUsedOrEstimate(used int, msgs []Message) int {
|
||||
if used > 0 {
|
||||
return used
|
||||
}
|
||||
return estimateTokens(msgs)
|
||||
}
|
||||
|
||||
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
|
||||
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
|
||||
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
|
||||
@@ -437,18 +478,24 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
||||
var index []recallEntry
|
||||
if caps.Agent && compactEnabled() {
|
||||
names := torsoToolNames(torso)
|
||||
var blocks []recallArchiveInput
|
||||
var at []int // position dans le torse de chaque bloc
|
||||
for i, m := range torso {
|
||||
if !recallEligible(m) {
|
||||
continue
|
||||
}
|
||||
content := msgText(m)
|
||||
label := recallLabel(m.Role, names[m.ToolCallID], content)
|
||||
id, aerr := archiveRecallBlock(label, m.Role, content)
|
||||
if aerr != nil {
|
||||
continue
|
||||
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
|
||||
at = append(at, i)
|
||||
}
|
||||
// Une seule transaction pour tous les blocs (AJEAN #130).
|
||||
if entries, aerr := archiveRecallBlocks(blocks); aerr == nil {
|
||||
for k, e := range entries {
|
||||
archived[at[k]] = &recallEntry{id: e.id, label: e.label}
|
||||
index = append(index, e)
|
||||
}
|
||||
archived[i] = &recallEntry{id: id, label: label}
|
||||
index = append(index, recallEntry{id: id, label: label})
|
||||
} else if len(blocks) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +575,14 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
||||
} else {
|
||||
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
}
|
||||
// La cause d'un résumé raté n'était écrite nulle part : on ne voyait que des
|
||||
// compactions « changé=false » à répétition, sans savoir pourquoi (AJEAN
|
||||
// 68659e73).
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé raté : %v\n", err)
|
||||
} else if summaryLooksEmpty(summary) {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé vide ou dégénéré (%d car.)\n", len([]rune(strings.TrimSpace(summary))))
|
||||
}
|
||||
var mid []Message
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Après une compaction en cours de tour qui n'a rien réduit, l'étape suivante
|
||||
// ne relance pas un résumé tant que le contexte n'a pas grossi de 5 % de la
|
||||
// fenêtre — sauf à 90 % de la fenêtre ou plus.
|
||||
func TestCompactRetryBackoff(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkConfig, "CTX", "100000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !compactRetryDue(0, 70000, nil) {
|
||||
t.Fatal("sans échec précédent, l'essai doit être permis")
|
||||
}
|
||||
next := compactNextRetry(false, 70000, nil)
|
||||
if next != 75000 {
|
||||
t.Fatalf("prochain essai à %d, attendu 75000", next)
|
||||
}
|
||||
if compactRetryDue(next, 72000, nil) {
|
||||
t.Fatal("relancé alors que le contexte n'a presque pas grossi")
|
||||
}
|
||||
if !compactRetryDue(next, 75000, nil) {
|
||||
t.Fatal("pas relancé alors que le contexte a grossi de 5 % de la fenêtre")
|
||||
}
|
||||
if !compactRetryDue(200000, 90000, nil) {
|
||||
t.Fatal("à 90 % de la fenêtre, l'essai doit toujours être permis")
|
||||
}
|
||||
if compactNextRetry(true, 70000, nil) != 0 {
|
||||
t.Fatal("une compaction réussie doit lever le garde-fou")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -112,7 +112,8 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
|
||||
}
|
||||
|
||||
// stripInlineImages remplace les images en base64 (data URI) portées par les
|
||||
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
|
||||
// messages multimodaux (vision : pièces jointes images et vidéos + outil
|
||||
// see_image) par un
|
||||
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
|
||||
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
|
||||
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
|
||||
@@ -145,6 +146,20 @@ func stripInlineImagesValue(v any) any {
|
||||
}
|
||||
cp["image_url"] = c2
|
||||
}
|
||||
// Partie vidéo (input_video, chat_video.go) : même élision, sur les deux
|
||||
// champs que lit llama-server.
|
||||
if iv, ok := cp["input_video"].(map[string]any); ok {
|
||||
c2 := make(map[string]any, len(iv))
|
||||
for k, val := range iv {
|
||||
c2[k] = val
|
||||
}
|
||||
for _, key := range []string{"url", "data"} {
|
||||
if s, ok := c2[key].(string); ok && strings.HasPrefix(s, "data:") {
|
||||
c2[key] = elideDataURI(s)
|
||||
}
|
||||
}
|
||||
cp["input_video"] = c2
|
||||
}
|
||||
return cp
|
||||
}
|
||||
switch parts := v.(type) {
|
||||
@@ -378,11 +393,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).
|
||||
|
||||
@@ -123,7 +123,11 @@ const chatImgKeep = 24 * time.Hour
|
||||
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
|
||||
// fil resté ouvert longtemps sans bascule.
|
||||
func pruneChatImages() {
|
||||
dir := chatImgDir()
|
||||
pruneMediaDir(chatImgDir())
|
||||
pruneMediaDir(chatVidDir()) // vidéos jointes (chat_video.go), même délai
|
||||
}
|
||||
|
||||
func pruneMediaDir(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -347,6 +347,9 @@ var (
|
||||
|
||||
const pageCacheTTL = 10 * time.Minute
|
||||
|
||||
// webNow : l'horloge du cache des pages (fixée par le journal de parité).
|
||||
var webNow = time.Now
|
||||
|
||||
type fetchOptions struct {
|
||||
force bool
|
||||
actions []string
|
||||
@@ -371,7 +374,7 @@ func findCached(rawURL string) *cacheEntry {
|
||||
var best *cacheEntry
|
||||
for k, v := range pageCache {
|
||||
if k == u || strings.HasPrefix(k, u+"#") {
|
||||
if time.Since(v.fetchedAt) > pageCacheTTL {
|
||||
if webNow().Sub(v.fetchedAt) > pageCacheTTL {
|
||||
continue
|
||||
}
|
||||
if best == nil || v.fetchedAt.After(best.fetchedAt) {
|
||||
@@ -388,7 +391,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
|
||||
pageCacheMu.Lock()
|
||||
cached := pageCache[key]
|
||||
pageCacheMu.Unlock()
|
||||
if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL {
|
||||
if !opts.force && cached != nil && webNow().Sub(cached.fetchedAt) < pageCacheTTL {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
@@ -418,7 +421,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
|
||||
if strings.TrimSpace(md) == "" {
|
||||
return nil, fmt.Errorf("page vide")
|
||||
}
|
||||
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()}
|
||||
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: webNow()}
|
||||
pageCacheMu.Lock()
|
||||
pageCache[key] = entry
|
||||
pageCacheMu.Unlock()
|
||||
@@ -493,6 +496,10 @@ func decodeHTMLEntities(s string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// ddgHTMLBase : le point d'entrée HTML de DuckDuckGo lu par le moteur intégré
|
||||
// (goSearch ; un faux pour les tests).
|
||||
var ddgHTMLBase = "https://html.duckduckgo.com"
|
||||
|
||||
// decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE
|
||||
func decodeUddg(raw string) string {
|
||||
s := raw
|
||||
|
||||
@@ -15,6 +15,10 @@ import (
|
||||
// L'IA y range ce qu'elle veut retenir entre les sessions (préférences,
|
||||
// décisions, procédures, infos projet). Quatre outils : mem_search, mem_read,
|
||||
// mem_add, mem_edit.
|
||||
//
|
||||
// Port Rust des outils (lot 40) : rust/loki-rs/src/memory/tools.rs, employé
|
||||
// par les tours de la façade quand elle tient la mémoire (LOKI_MEM=rust) ;
|
||||
// journal de parité dans parity_mem_tools_test.go.
|
||||
|
||||
type MemPage struct {
|
||||
Name string // nom de fichier sans le dossier (ex: "docker-notes.md")
|
||||
|
||||
@@ -536,6 +536,7 @@ func (c *Conversation) snapshot() *convSnap {
|
||||
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
|
||||
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
|
||||
refImagesInMessages(c.Messages)
|
||||
refVideosInMessages(c.Messages) // vidéos jointes, même rangement (chat_video.go)
|
||||
// En plein tour, le journal porte encore un événement par token et chaque
|
||||
// frappe d'outil : on écrit la forme que produira la fin de tour
|
||||
// (compactLog), sans toucher au journal en mémoire que suivent les abonnés.
|
||||
|
||||
@@ -245,6 +245,8 @@ func prewarmNearMidnight(now time.Time) bool {
|
||||
|
||||
// Points d'accroche remplaçables par les tests.
|
||||
var (
|
||||
// prewarmNow : l'heure du garde-fou de minuit.
|
||||
prewarmNow = time.Now
|
||||
// prewarmAsync : false = le préchauffage tourne dans l'appelant (tests).
|
||||
prewarmAsync = true
|
||||
// prewarmSlots : nombre de slots du moteur local (/props total_slots).
|
||||
@@ -265,10 +267,16 @@ var (
|
||||
)
|
||||
|
||||
// prewarmKick : un déclencheur. Sans la clé, rien — ni goroutine, ni requête.
|
||||
// Quand la façade tient la conversation, le déclencheur lui part
|
||||
// (facade_prewarm.go) : c'est elle qui a l'historique.
|
||||
func (c *Conversation) prewarmKick(why string) {
|
||||
if prewarmMode(ReadConfig()) == "" {
|
||||
return
|
||||
}
|
||||
if facadeChat() {
|
||||
go func() { _ = facadePost("/internal/facade/v1/chat/prewarm", map[string]any{"why": why}) }()
|
||||
return
|
||||
}
|
||||
if prewarmAsync {
|
||||
go c.prewarm(why)
|
||||
return
|
||||
@@ -375,7 +383,7 @@ func (c *Conversation) prewarm(why string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(time.Now()) {
|
||||
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(prewarmNow()) {
|
||||
return
|
||||
}
|
||||
if !healthCheck() {
|
||||
|
||||
@@ -79,6 +79,11 @@ func (m *moteurPrewarm) completions() []string {
|
||||
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
|
||||
// préchauffage synchrone. keys : réglages posés.
|
||||
func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm {
|
||||
// Le préchauffage se tait dans les dix minutes avant minuit : midi ici,
|
||||
// pour que les tests ne dépendent pas de l'heure à laquelle ils tournent.
|
||||
oldNow := prewarmNow
|
||||
prewarmNow = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local) }
|
||||
t.Cleanup(func() { prewarmNow = oldNow })
|
||||
t.Helper()
|
||||
withWorkspace(t)
|
||||
activeProjMu.Lock()
|
||||
|
||||
+105
-60
@@ -78,58 +78,88 @@ func recallIDToSeq(id string) (uint64, bool) {
|
||||
return n, true
|
||||
}
|
||||
|
||||
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
|
||||
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
|
||||
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
|
||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
// recallArchiveInput : un bloc à archiver (voir archiveRecallBlocks).
|
||||
type recallArchiveInput struct {
|
||||
Label string
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
// archiveRecallBlocks enregistre tous les blocs d'une compaction dans UNE
|
||||
// transaction et renvoie leurs entrées, dans l'ordre des blocs fournis. Les ids
|
||||
// sont alloués par NextSequence dans la transaction : monotones, sans
|
||||
// collision, même si deux compactages se croisent. Une transaction par bloc
|
||||
// payait une ouverture de la base et une synchronisation disque chacune, sous
|
||||
// le verrou global de la base, en plein tour (AJEAN #130). Tout ou rien : en
|
||||
// cas d'échec (base indisponible, ex. tests sans LOKI_HOME, mémoire
|
||||
// verrouillée) aucun id n'est rendu, et l'appelant se rabat sur le compactage
|
||||
// classique (troncature sans id).
|
||||
func archiveRecallBlocks(blocks []recallArchiveInput) ([]recallEntry, error) {
|
||||
if len(blocks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||
encode, err := memEncoderNow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
var id string
|
||||
now := time.Now().Unix()
|
||||
var entries []recallEntry
|
||||
err = withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
entries = entries[:0]
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
for _, in := range blocks {
|
||||
seq, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blk := recallBlock{
|
||||
ID: "r" + strconv.FormatUint(seq, 10),
|
||||
Seq: seq,
|
||||
Label: in.Label,
|
||||
Role: in.Role,
|
||||
Content: in.Content,
|
||||
Created: now,
|
||||
}
|
||||
raw, err := json.Marshal(blk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré
|
||||
// comme le reste quand la mémoire l'est (l'encodeur rend le clair
|
||||
// inchangé si le chiffrement est inactif ; verrouillé,
|
||||
// memEncoderNow a déjà refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Put(recallKey(seq), enc); err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, recallEntry{id: blk.ID, label: blk.Label})
|
||||
}
|
||||
blk := recallBlock{
|
||||
ID: "r" + strconv.FormatUint(seq, 10),
|
||||
Seq: seq,
|
||||
Label: label,
|
||||
Role: role,
|
||||
Content: content,
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
id = blk.ID
|
||||
raw, err := json.Marshal(blk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
|
||||
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
|
||||
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
|
||||
// refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), enc)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
cacheBust(bkRecall)
|
||||
return id, nil
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// archiveRecallBlock enregistre un seul bloc et renvoie son id ("r<seq>").
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
e, err := archiveRecallBlocks([]recallArchiveInput{{Label: label, Role: role, Content: content}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e[0].id, nil
|
||||
}
|
||||
|
||||
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
|
||||
@@ -138,6 +168,11 @@ func recallGet(id string) (recallBlock, bool) {
|
||||
if !ok {
|
||||
return recallBlock{}, false
|
||||
}
|
||||
return recallGetSeq(seq)
|
||||
}
|
||||
|
||||
// recallGetSeq ramène un bloc par sa séquence (aussi servi à la façade).
|
||||
func recallGetSeq(seq uint64) (recallBlock, bool) {
|
||||
var blk recallBlock
|
||||
found := false
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
@@ -155,6 +190,27 @@ func recallGet(id string) (recallBlock, bool) {
|
||||
return blk, found
|
||||
}
|
||||
|
||||
// recallAll : tous les blocs lisibles, par séquence croissante (un bloc
|
||||
// chiffré sous une mémoire verrouillée n'en fait pas partie).
|
||||
func recallAll() []recallBlock {
|
||||
var out []recallBlock
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var blk recallBlock
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if json.Unmarshal(dec, &blk) != nil {
|
||||
return nil
|
||||
}
|
||||
out = append(out, blk)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// recallSearch cherche dans toute l'archive par recouvrement de mots-clés.
|
||||
// Score = nombre de termes distincts de la requête présents dans (libellé +
|
||||
// contenu), le libellé comptant double (un mot du titre est plus signifiant).
|
||||
@@ -176,33 +232,22 @@ func recallSearch(query string, limit int) []recallBlock {
|
||||
score int
|
||||
}
|
||||
var hits []scored
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var blk recallBlock
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : ce bloc ne participe pas
|
||||
for _, blk := range recallAll() {
|
||||
label := strings.ToLower(blk.Label)
|
||||
body := strings.ToLower(blk.Content)
|
||||
score := 0
|
||||
for t := range terms {
|
||||
if strings.Contains(label, t) {
|
||||
score += 2
|
||||
}
|
||||
if json.Unmarshal(dec, &blk) != nil {
|
||||
return nil
|
||||
if strings.Contains(body, t) {
|
||||
score++
|
||||
}
|
||||
label := strings.ToLower(blk.Label)
|
||||
body := strings.ToLower(blk.Content)
|
||||
score := 0
|
||||
for t := range terms {
|
||||
if strings.Contains(label, t) {
|
||||
score += 2
|
||||
}
|
||||
if strings.Contains(body, t) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
hits = append(hits, scored{blk, score})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
if score > 0 {
|
||||
hits = append(hits, scored{blk, score})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
if hits[i].score != hits[j].score {
|
||||
return hits[i].score > hits[j].score
|
||||
|
||||
@@ -221,3 +221,47 @@ func TestCompactSummaryUserMsg(t *testing.T) {
|
||||
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
|
||||
}
|
||||
}
|
||||
|
||||
// Les blocs d'une compaction s'écrivent en une seule transaction : ids dans
|
||||
// l'ordre des blocs, croissants, chacun relisible mot pour mot.
|
||||
func TestArchiveRecallBlocksBatch(t *testing.T) {
|
||||
testHome(t)
|
||||
if e, err := archiveRecallBlocks(nil); err != nil || e != nil {
|
||||
t.Fatalf("lot vide : %v, %v", e, err)
|
||||
}
|
||||
in := []recallArchiveInput{
|
||||
{Label: "un", Role: "tool", Content: bigBlock("PREMIER")},
|
||||
{Label: "deux", Role: "user", Content: bigBlock("SECOND")},
|
||||
{Label: "trois", Role: "assistant", Content: bigBlock("TROISIEME")},
|
||||
}
|
||||
entries, err := archiveRecallBlocks(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != len(in) {
|
||||
t.Fatalf("%d entrées pour %d blocs", len(entries), len(in))
|
||||
}
|
||||
var last uint64
|
||||
for i, e := range entries {
|
||||
seq, ok := recallIDToSeq(e.id)
|
||||
if !ok || seq <= last {
|
||||
t.Fatalf("id %q non croissant (précédent %d)", e.id, last)
|
||||
}
|
||||
last = seq
|
||||
if e.label != in[i].Label {
|
||||
t.Fatalf("entrée %d : libellé %q, attendu %q", i, e.label, in[i].Label)
|
||||
}
|
||||
blk, ok := recallGet(e.id)
|
||||
if !ok || blk.Content != in[i].Content || blk.Role != in[i].Role {
|
||||
t.Fatalf("bloc %s mal relu : %+v", e.id, blk)
|
||||
}
|
||||
}
|
||||
// Un archivage isolé suit la même séquence.
|
||||
id, err := archiveRecallBlock("quatre", "tool", bigBlock("QUATRE"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seq, _ := recallIDToSeq(id); seq != last+1 {
|
||||
t.Fatalf("id isolé %q, attendu r%d", id, last+1)
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,8 @@ func engineSeesImages() bool {
|
||||
// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le
|
||||
// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire
|
||||
// au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne.
|
||||
// Vaut pour toutes les provenances : capture, pièce jointe, see_image.
|
||||
// Vaut pour toutes les provenances : capture, pièce jointe, see_image — et pour
|
||||
// les vidéos jointes (input_video), marquées par videoLostMarker.
|
||||
func stripImageParts(msgs []Message) []Message {
|
||||
for i, m := range msgs {
|
||||
parts, ok := m.Content.([]any)
|
||||
@@ -171,22 +172,33 @@ func stripImageParts(msgs []Message) []Message {
|
||||
continue
|
||||
}
|
||||
var texts []string
|
||||
dropped := false
|
||||
dropped, droppedVid := false, false
|
||||
for _, p := range parts {
|
||||
pm, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if pm["type"] == "image_url" {
|
||||
switch pm["type"] {
|
||||
case "image_url":
|
||||
dropped = true
|
||||
continue
|
||||
case "input_video": // vidéo jointe (chat_video.go) : même sort
|
||||
droppedVid = true
|
||||
continue
|
||||
}
|
||||
if t, ok := pm["text"].(string); ok {
|
||||
texts = append(texts, t)
|
||||
}
|
||||
}
|
||||
if dropped {
|
||||
msgs[i].Content = strings.Join(texts, "\n") + imageLostMarker
|
||||
if dropped || droppedVid {
|
||||
text := strings.Join(texts, "\n")
|
||||
if dropped {
|
||||
text += imageLostMarker
|
||||
}
|
||||
if droppedVid {
|
||||
text += videoLostMarker
|
||||
}
|
||||
msgs[i].Content = text
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
|
||||
@@ -41,11 +41,15 @@ func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
|
||||
}
|
||||
testHome(t)
|
||||
start := time.Now()
|
||||
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
|
||||
if d := time.Since(start); d > 10*time.Second {
|
||||
// Délai de 10 s, bien au-delà de WaitDelay (2 s) : c'est le détachement
|
||||
// qui rend la main, pas le délai de la commande.
|
||||
out := runShell(context.Background(), "sleep 30 & echo lance", 10)
|
||||
if d := time.Since(start); d > 8*time.Second {
|
||||
t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
|
||||
}
|
||||
if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
|
||||
// La commande a fini (exit 0) : sa sortie est rendue, pas effacée par
|
||||
// « WaitDelay expired » (AJEAN 7403a8a0).
|
||||
if !strings.Contains(out, "exit: 0") || !strings.Contains(out, "lance") || !strings.Contains(out, "arrière-plan") {
|
||||
t.Errorf("sortie inattendue : %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,14 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// issue connue était de redémarrer loki-ui.
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
err := cmd.Run()
|
||||
// ErrWaitDelay : la commande s'est terminée normalement, mais un process
|
||||
// laissé en arrière-plan tenait encore les tubes. Ce qu'elle a écrit est
|
||||
// déjà capturé : le rendre, au lieu de « [erreur: exec: WaitDelay expired
|
||||
// before I/O complete] » qui l'effaçait (repris d'AJEAN 7403a8a0).
|
||||
detached := errors.Is(err, exec.ErrWaitDelay)
|
||||
if detached {
|
||||
err = nil
|
||||
}
|
||||
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
|
||||
// demandait un tail.
|
||||
clean := func(s string) string { return tailOutput(stripANSI(s)) }
|
||||
@@ -337,6 +345,9 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
if errOut != "" {
|
||||
parts = append(parts, "stderr:\n"+errOut)
|
||||
}
|
||||
if detached {
|
||||
parts = append(parts, "(un process lancé en arrière-plan tourne toujours ; sa sortie n'est plus suivie — pour un serveur, utilise bash_bg)")
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,9 @@ func tplCapsCurrent() (tplProbeResult, bool) {
|
||||
if externalActive() {
|
||||
return tplProbeResult{}, false
|
||||
}
|
||||
if facadeChat() { // la façade sonde après SES complétions (facade_chat.go)
|
||||
return facadeTplCaps()
|
||||
}
|
||||
tplProbeMu.Lock()
|
||||
defer tplProbeMu.Unlock()
|
||||
if tplProbeLast == nil {
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package loki
|
||||
|
||||
// chat_video.go — vidéos vues par le modèle (repris d'AJEAN, PR #100 et son
|
||||
// correctif « vidéo envoyée seulement si le moteur sait la lire »).
|
||||
//
|
||||
// Trois morceaux :
|
||||
// - videoInputSupported : le moteur actif sait-il lire une partie
|
||||
// `input_video` ? Seul un llama.cpp local récent le fait (option
|
||||
// --video-fps, décodage par ffmpeg). Une API externe ou Strata ignorent la
|
||||
// partie SANS erreur : le modèle ne recevait que le texte et cherchait à voir
|
||||
// la vidéo avec see_image. Ailleurs, la vidéo reste un simple fichier, avec
|
||||
// la marche à suivre (videoFallbackNote) ;
|
||||
// - une vidéo JOINTE au message est rangée PAR RÉFÉRENCE, comme les images
|
||||
// (chat_images.go) : une fois sur disque sous LOKI_HOME/chatvid/, chiffrée
|
||||
// comme le fil, et l'historique n'en garde que « loki-vid:<nom> ».
|
||||
// expandVideoRefs remet les octets juste avant l'envoi au moteur ;
|
||||
// - la vidéo chargée par l'outil see_video (chat_video_tool.go) est, elle,
|
||||
// TOUJOURS éphémère : elle part dans le tour en cours et jamais dans
|
||||
// l'historique, KEEP_TURN_IMAGES compris (des milliers de jetons visuels).
|
||||
//
|
||||
// Écart avec l'amont : comme les images, un fil rechargé perd ses vidéos
|
||||
// (stripImageParts, avec videoLostMarker) et pruneChatImages vide aussi
|
||||
// chatvid/ — Loki ne rejoue jamais un média d'un fil rouvert.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const vidRefScheme = "loki-vid:"
|
||||
|
||||
func chatVidDir() string { return filepath.Join(LokiHome(), "chatvid") }
|
||||
|
||||
// maxVideoBytes borne la taille d'une vidéo montrée au modèle : le moteur en
|
||||
// tire des images à --video-fps (4 par seconde par défaut), donc une vidéo
|
||||
// longue = des dizaines de milliers de jetons visuels. Au-delà, le contexte
|
||||
// sature sans que le modèle puisse raisonner dessus.
|
||||
const maxVideoBytes = 200 << 20 // 200 Mio
|
||||
|
||||
// videoMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme vidéo (partie
|
||||
// input_video). Le moteur décode lui-même avec ffmpeg ; un format hors de cette
|
||||
// liste reste un simple fichier du dossier de travail.
|
||||
var videoMimes = map[string]string{
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
}
|
||||
|
||||
// videoMime renvoie le type MIME vidéo d'un nom de fichier, ou "" si ce n'est
|
||||
// pas une vidéo qu'on sait montrer au modèle.
|
||||
func videoMime(name string) string {
|
||||
return videoMimes[strings.ToLower(filepath.Ext(name))]
|
||||
}
|
||||
|
||||
// videoFallbackNote : consigne quand le moteur ne lit pas la vidéo. Le fichier
|
||||
// reste utilisable : quelques images extraites avec ffmpeg, puis see_image.
|
||||
// ffmpeg est dans l'image Docker de Loki (l'image serveur officielle de
|
||||
// llama.cpp l'installe pour son décodage vidéo) ; une installation hors
|
||||
// conteneur peut ne pas l'avoir, d'où la dernière phrase.
|
||||
const videoFallbackNote = "Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.\n"
|
||||
|
||||
// videoLostMarker : équivalent d'imageLostMarker pour une partie input_video
|
||||
// (résumé de compaction, fil rechargé).
|
||||
const videoLostMarker = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]"
|
||||
|
||||
// videoPart construit la partie multimodale {type:input_video, input_video:{url}}
|
||||
// — le format que llama-server lit une fois --mmproj chargé.
|
||||
func videoPart(b []byte, mime string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "input_video",
|
||||
"input_video": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// videoInputSupported : le moteur actif sait-il lire une partie input_video ?
|
||||
// Preset local avec projecteur (MMPROJ) ET binaire dont l'aide annonce
|
||||
// --video-fps. Variable : remplaçable dans les tests.
|
||||
var videoInputSupported = func() bool {
|
||||
cfg := ReadConfig()
|
||||
if isExternalConfig(cfg) || isStrataConfig(cfg) || strings.TrimSpace(cfg["MMPROJ"]) == "" {
|
||||
return false
|
||||
}
|
||||
bin := strings.TrimSpace(cfg["BIN"])
|
||||
if bin == "" {
|
||||
return false
|
||||
}
|
||||
if !filepath.IsAbs(bin) {
|
||||
bin = filepath.Join(LokiHome(), bin)
|
||||
}
|
||||
return binHelpHasVideo(prebuiltResolveBin(bin))
|
||||
}
|
||||
|
||||
var videoCapCache sync.Map // "binaire\x00mtime" → bool
|
||||
|
||||
// binHelpHasVideo lit l'aide du binaire, une fois par binaire ET par date de
|
||||
// modification (un moteur mis à jour en place est relu). Volontairement à part
|
||||
// de binHelp : celle-ci garde aussi une aide VIDE pour toujours, et l'appeler
|
||||
// avant le lancement du moteur (chemin des bibliothèques pas encore posé)
|
||||
// figerait « aucune capacité » pour le vrai lancement. Ici, le chemin des
|
||||
// bibliothèques est passé à la seule commande (libraryPathEnv).
|
||||
func binHelpHasVideo(bin string) bool {
|
||||
st, err := os.Stat(bin)
|
||||
if err != nil || st.IsDir() {
|
||||
return false
|
||||
}
|
||||
key := bin + "\x00" + st.ModTime().String()
|
||||
if v, ok := videoCapCache.Load(key); ok {
|
||||
return v.(bool)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, "--help")
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, _ := cmd.CombinedOutput()
|
||||
ok := strings.Contains(string(out), "--video-fps")
|
||||
videoCapCache.Store(key, ok)
|
||||
return ok
|
||||
}
|
||||
|
||||
// --- Vidéos jointes, par référence -------------------------------------------
|
||||
|
||||
var vidExtByMime = map[string]string{
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"video/quicktime": ".mov",
|
||||
"video/x-matroska": ".mkv",
|
||||
"video/x-msvideo": ".avi",
|
||||
}
|
||||
|
||||
// storeChatVideo range les octets d'une vidéo et renvoie sa référence. Chiffrée
|
||||
// comme le fil quand la mémoire l'est ; verrouillée : refus, la vidéo reste en
|
||||
// base64 dans le message (rien ne se perd).
|
||||
func storeChatVideo(b []byte, mime string) (string, error) {
|
||||
ext := vidExtByMime[mime]
|
||||
if ext == "" {
|
||||
ext = ".vid"
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
name := hex.EncodeToString(sum[:16]) + ext
|
||||
dir := chatVidDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
enc, err := encodeMemContent(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return vidRefScheme + name, nil
|
||||
}
|
||||
|
||||
// Cache des data-URL reconstituées (même raison que imgCache : un tour d'agent
|
||||
// renvoie tout l'historique à chaque étape). Borné en taille.
|
||||
var (
|
||||
vidCacheMu sync.Mutex
|
||||
vidCache = map[string]string{}
|
||||
vidCacheOrder []string
|
||||
vidCacheBytes int
|
||||
)
|
||||
|
||||
const vidCacheMax = 64 << 20
|
||||
|
||||
func chatVideoDataURL(ref string) (string, bool) {
|
||||
name := strings.TrimPrefix(ref, vidRefScheme)
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return "", false
|
||||
}
|
||||
vidCacheMu.Lock()
|
||||
if u, ok := vidCache[name]; ok {
|
||||
vidCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
vidCacheMu.Unlock()
|
||||
raw, err := os.ReadFile(filepath.Join(chatVidDir(), name))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
b, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
mime := "video/mp4"
|
||||
for m, e := range vidExtByMime {
|
||||
if strings.HasSuffix(name, e) {
|
||||
mime = m
|
||||
}
|
||||
}
|
||||
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
|
||||
vidCacheMu.Lock()
|
||||
if _, ok := vidCache[name]; !ok {
|
||||
vidCache[name] = u
|
||||
vidCacheOrder = append(vidCacheOrder, name)
|
||||
vidCacheBytes += len(u)
|
||||
for vidCacheBytes > vidCacheMax && len(vidCacheOrder) > 1 {
|
||||
old := vidCacheOrder[0]
|
||||
vidCacheOrder = vidCacheOrder[1:]
|
||||
vidCacheBytes -= len(vidCache[old])
|
||||
delete(vidCache, old)
|
||||
}
|
||||
}
|
||||
vidCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
|
||||
// partVideoURL : l'adresse d'une partie input_video (url, sinon data — les deux
|
||||
// formes que lit llama-server), "" pour toute autre partie.
|
||||
func partVideoURL(p map[string]any) string {
|
||||
if p["type"] != "input_video" {
|
||||
return ""
|
||||
}
|
||||
iv, _ := p["input_video"].(map[string]any)
|
||||
if iv == nil {
|
||||
return ""
|
||||
}
|
||||
u, _ := iv["url"].(string)
|
||||
if u == "" {
|
||||
u, _ = iv["data"].(string)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// refVideosInMessages remplace les vidéos en base64 des messages par des
|
||||
// références. Comme refImagesInMessages, les parties modifiées sont RECOPIÉES,
|
||||
// jamais mutées (persist peut tourner pendant qu'un runChat sérialise ces mêmes
|
||||
// maps). Renvoie true si quelque chose a changé.
|
||||
func refVideosInMessages(msgs []Message) bool {
|
||||
changed := false
|
||||
for mi, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
var np []map[string]any
|
||||
for pi, p := range parts {
|
||||
u := partVideoURL(p)
|
||||
if !strings.HasPrefix(u, "data:video/") {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(u, ";base64,")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(u[i+8:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ref, err := storeChatVideo(b, u[5:i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if np == nil {
|
||||
np = append([]map[string]any(nil), parts...)
|
||||
}
|
||||
np[pi] = map[string]any{"type": "input_video", "input_video": map[string]any{"url": ref}}
|
||||
}
|
||||
if np != nil {
|
||||
msgs[mi].Content = np
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hasInlineVideos : au moins une vidéo encore en base64 dans ces messages ?
|
||||
func hasInlineVideos(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
for _, p := range contentParts(m.Content) {
|
||||
if strings.HasPrefix(partVideoURL(p), "data:video/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expandVideoRefs renvoie une COPIE des messages prête pour le modèle : chaque
|
||||
// référence de vidéo redevient sa data-URL. Les messages sans référence sont
|
||||
// partagés tels quels. Une vidéo introuvable devient une mention textuelle.
|
||||
func expandVideoRefs(msgs []Message) []Message {
|
||||
out := msgs
|
||||
copied := false
|
||||
for i, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
has := false
|
||||
for _, p := range parts {
|
||||
if strings.HasPrefix(partVideoURL(p), vidRefScheme) {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if !copied {
|
||||
out = append([]Message(nil), msgs...)
|
||||
copied = true
|
||||
}
|
||||
np := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
u := partVideoURL(p)
|
||||
if !strings.HasPrefix(u, vidRefScheme) {
|
||||
np = append(np, p)
|
||||
continue
|
||||
}
|
||||
if d, ok := chatVideoDataURL(u); ok {
|
||||
np = append(np, map[string]any{"type": "input_video", "input_video": map[string]any{"url": d}})
|
||||
} else {
|
||||
np = append(np, map[string]any{"type": "text", "text": "[vidéo indisponible : fichier supprimé]"})
|
||||
}
|
||||
}
|
||||
m.Content = np
|
||||
out[i] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// videoEngine simule un moteur qui sait (ou non) lire la vidéo.
|
||||
func videoEngine(t *testing.T, ok bool) {
|
||||
t.Helper()
|
||||
prev := videoInputSupported
|
||||
videoInputSupported = func() bool { return ok }
|
||||
t.Cleanup(func() { videoInputSupported = prev })
|
||||
}
|
||||
|
||||
// engineSees amorce le cache de la sonde /props : aucun llama-server ne tourne
|
||||
// pendant les tests.
|
||||
func engineSees(t *testing.T, seen bool) {
|
||||
t.Helper()
|
||||
visionProbeMu.Lock()
|
||||
visionProbeAt, visionProbeSeen = time.Now(), seen
|
||||
visionProbeMu.Unlock()
|
||||
t.Cleanup(func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() })
|
||||
}
|
||||
|
||||
// Une vidéo en base64 part sur disque et revient identique à l'envoi au modèle ;
|
||||
// l'historique ne garde qu'une référence de quelques octets.
|
||||
func TestVideoRefsRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
raw := []byte(strings.Repeat("\x00\x00\x00\x18ftypmp42-fake-video-bytes", 5000))
|
||||
url := "data:video/mp4;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde la vidéo"},
|
||||
{"type": "input_video", "input_video": map[string]any{"url": url}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
// Relu depuis le JSON (forme []any), comme après un redémarrage.
|
||||
var reread []Message
|
||||
b, _ := json.Marshal(msgs)
|
||||
_ = json.Unmarshal(b, &reread)
|
||||
|
||||
for _, set := range [][]Message{msgs, reread} {
|
||||
orig := contentParts(set[0].Content)[1]
|
||||
if !hasInlineVideos(set) || !refVideosInMessages(set) || hasInlineVideos(set) {
|
||||
t.Fatal("la vidéo aurait dû être rangée par référence")
|
||||
}
|
||||
// Recopiée, jamais mutée : un runChat en vol peut lire ces mêmes maps.
|
||||
if partVideoURL(orig) != url {
|
||||
t.Fatal("refVideosInMessages a muté la partie d'origine")
|
||||
}
|
||||
js, _ := json.Marshal(set)
|
||||
if len(js) > 500 || !strings.Contains(string(js), vidRefScheme) {
|
||||
t.Fatalf("historique encore lourd : %d octets", len(js))
|
||||
}
|
||||
out := expandVideoRefs(set)
|
||||
if got := partVideoURL(contentParts(out[0].Content)[1]); got != url {
|
||||
t.Fatal("la vidéo renvoyée au modèle diffère de l'originale")
|
||||
}
|
||||
// L'historique lui-même n'est pas modifié par l'expansion.
|
||||
if u := partVideoURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, vidRefScheme) {
|
||||
t.Fatal("expandVideoRefs ne doit pas toucher l'historique")
|
||||
}
|
||||
}
|
||||
// Fichier effacé (ménage) : une mention, pas une requête qui échoue.
|
||||
_ = os.RemoveAll(chatVidDir())
|
||||
vidCacheMu.Lock()
|
||||
vidCache, vidCacheOrder, vidCacheBytes = map[string]string{}, nil, 0
|
||||
vidCacheMu.Unlock()
|
||||
out := expandVideoRefs(msgs)
|
||||
if p := contentParts(out[0].Content)[1]; p["type"] != "text" || !strings.Contains(p["text"].(string), "indisponible") {
|
||||
t.Fatalf("vidéo disparue : %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// userMessageContent : une vidéo jointe devient une partie input_video (et une
|
||||
// image jointe reste une partie image_url), quand la vision est active et que
|
||||
// le moteur lit la vidéo.
|
||||
func TestUserMessageContentVideo(t *testing.T) {
|
||||
videoEngine(t, true)
|
||||
visionOn(t)
|
||||
engineSees(t, true)
|
||||
dir, err := uploadsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "clip-test.mp4"), []byte("fake-mp4-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "photo.png"), pngPixel, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := []attachInfo{
|
||||
{Name: "clip-test.mp4", Path: "uploads/clip-test.mp4", Size: 14},
|
||||
{Name: "photo.png", Path: "uploads/photo.png", Size: int64(len(pngPixel))},
|
||||
}
|
||||
parts, ok := userMessageContent(in, "décris ce clip").([]map[string]any)
|
||||
if !ok || len(parts) != 3 {
|
||||
t.Fatalf("attendu texte + image + vidéo, obtenu %#v", parts)
|
||||
}
|
||||
if parts[0]["type"] != "text" || parts[1]["type"] != "image_url" || parts[2]["type"] != "input_video" {
|
||||
t.Fatalf("ordre des parties : %v %v %v", parts[0]["type"], parts[1]["type"], parts[2]["type"])
|
||||
}
|
||||
if u := partVideoURL(parts[2]); !strings.HasPrefix(u, "data:video/mp4;base64,") {
|
||||
t.Fatalf("data URI vidéo inattendue : %.40q", u)
|
||||
}
|
||||
// Montrée, elle n'est plus annoncée comme simple fichier.
|
||||
if txt := parts[0]["text"].(string); strings.Contains(txt, "clip-test.mp4") || strings.Contains(txt, "ffmpeg") {
|
||||
t.Fatalf("vidéo vue annoncée comme fichier : %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// Moteur sans vidéo (API externe, Strata, llama.cpp ancien) : il ignorait la
|
||||
// partie input_video sans erreur et le modèle ne recevait que le texte. La
|
||||
// vidéo doit alors être annoncée comme fichier, avec la marche à suivre
|
||||
// (ffmpeg puis see_image), et see_video doit le dire au lieu de charger.
|
||||
func TestVideoUnsupportedEngine(t *testing.T) {
|
||||
videoEngine(t, false)
|
||||
visionOn(t)
|
||||
engineSees(t, true)
|
||||
dir, err := uploadsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vname := "clip-test.mp4"
|
||||
if err := os.WriteFile(filepath.Join(dir, vname), []byte("fake-mp4-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := userMessageContent([]attachInfo{{Name: vname, Path: "uploads/" + vname, Size: 14}}, "décris ce clip")
|
||||
s, ok := content.(string)
|
||||
if !ok {
|
||||
t.Fatalf("attendu du texte seul (pas de partie input_video), obtenu %T", content)
|
||||
}
|
||||
if !strings.Contains(s, "uploads/"+vname) || !strings.Contains(s, "ffmpeg") || !strings.HasSuffix(s, "décris ce clip") {
|
||||
t.Fatalf("chemin et consigne ffmpeg attendus : %q", s)
|
||||
}
|
||||
if msg, part := toolSeeVideo(filepath.Join(dir, vname)); part != nil || !strings.Contains(msg, "ffmpeg") {
|
||||
t.Fatalf("see_video doit refuser avec la consigne ffmpeg : %q", msg)
|
||||
}
|
||||
// Ni outil annoncé, ni consigne quand ce n'est pas une vidéo.
|
||||
for _, tl := range EnabledTools(Caps{Agent: true}) {
|
||||
if tl.Function.Name == "see_video" {
|
||||
t.Fatal("see_video annoncé alors que le moteur ne lit pas la vidéo")
|
||||
}
|
||||
}
|
||||
if s := attachNote([]attachInfo{{Name: "a.txt", Path: "uploads/a.txt", Size: 1}}); strings.Contains(s, "ffmpeg") {
|
||||
t.Fatalf("consigne vidéo sur un fichier texte : %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// see_video : annoncé avec see_image quand le moteur lit la vidéo.
|
||||
func TestSeeVideoAnnonce(t *testing.T) {
|
||||
videoEngine(t, true)
|
||||
visionOn(t)
|
||||
has := func(caps Caps) bool {
|
||||
for _, tl := range EnabledTools(caps) {
|
||||
if tl.Function.Name == "see_video" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has(Caps{Agent: true}) {
|
||||
t.Fatal("see_video absent alors que le moteur lit la vidéo")
|
||||
}
|
||||
if has(Caps{}) {
|
||||
t.Fatal("see_video hors du mode agent")
|
||||
}
|
||||
if toolCallLabel("see_video", map[string]any{"file": "a.mp4"}) != "a.mp4" {
|
||||
t.Fatal("libellé de see_video vide : l'outil s'exécuterait sur une chaîne vide")
|
||||
}
|
||||
if dedupableTool("see_video") {
|
||||
t.Fatal("see_video dédupliqué : le rappel rendrait « déjà fait » sans la vidéo")
|
||||
}
|
||||
}
|
||||
|
||||
// toolSeeVideo : chaque refus nomme son motif ; un mp4 présent est chargé et
|
||||
// relayé dans un message utilisateur qui nomme le fichier.
|
||||
func TestToolSeeVideo(t *testing.T) {
|
||||
videoEngine(t, true)
|
||||
home := visionOn(t)
|
||||
engineSees(t, true)
|
||||
if err := os.MkdirAll(filepath.Join(home, "dossier.mp4"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gros := filepath.Join(home, "gros.mp4")
|
||||
f, _ := os.Create(gros)
|
||||
_ = f.Truncate(maxVideoBytes + 1)
|
||||
f.Close()
|
||||
_ = os.WriteFile(filepath.Join(home, "vide.webm"), nil, 0o600)
|
||||
cas := []struct{ path, attendu string }{
|
||||
{"", "chemin de fichier manquant"},
|
||||
{filepath.Join(home, "notes.txt"), "format non reconnu comme vidéo"},
|
||||
{filepath.Join(home, "nulle-part.mp4"), "introuvable"},
|
||||
{filepath.Join(home, "dossier.mp4"), "dossier"},
|
||||
{gros, "trop lourde"},
|
||||
{filepath.Join(home, "vide.webm"), "fichier vide"},
|
||||
}
|
||||
for _, c := range cas {
|
||||
msg, part := toolSeeVideo(c.path)
|
||||
if part != nil || !strings.HasPrefix(msg, "[erreur]") || !strings.Contains(msg, c.attendu) {
|
||||
t.Errorf("%q : %q, attendu une erreur contenant %q", c.path, msg, c.attendu)
|
||||
}
|
||||
}
|
||||
p := filepath.Join(home, "clip.MOV")
|
||||
if err := os.WriteFile(p, []byte("fake-mov"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg, part := toolSeeVideo(p)
|
||||
if part == nil || msg != "[ok] vidéo chargée : clip.MOV" {
|
||||
t.Fatalf("attendu un [ok] : %q / %v", msg, part)
|
||||
}
|
||||
if u := partVideoURL(part); u != "data:video/quicktime;base64,"+base64.StdEncoding.EncodeToString([]byte("fake-mov")) {
|
||||
t.Fatalf("partie inattendue : %v", part)
|
||||
}
|
||||
// Le moteur en service ne voit pas : on le dit au lieu d'envoyer 200 Mio.
|
||||
engineSees(t, false)
|
||||
if msg, part := toolSeeVideo(p); part != nil || !strings.Contains(msg, "ne traite pas les images") {
|
||||
t.Fatalf("moteur aveugle : %q", msg)
|
||||
}
|
||||
// Relais : passé par l'API interne comme see_image (toolStepExtras).
|
||||
_, _, relay := toolStepExtras("see_video", "clip.MOV", toolExecResult{Result: "[ok]", VisionVideo: videoPart([]byte("x"), "video/mp4")})
|
||||
if relay == nil {
|
||||
t.Fatal("aucun relais pour see_video")
|
||||
}
|
||||
rp := contentParts(relay.Content)
|
||||
if relay.Role != "user" || rp[0]["text"] != "Vidéo demandée (clip.MOV) :" || rp[1]["type"] != "input_video" {
|
||||
t.Fatalf("relais inattendu : %#v", relay)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans projecteur ou sur un poste distant : refus explicite, avant toute
|
||||
// question au moteur.
|
||||
func TestSeeVideoSansVision(t *testing.T) {
|
||||
videoEngine(t, true)
|
||||
testHome(t)
|
||||
if msg, part := toolSeeVideo("a.mp4"); part != nil || !strings.Contains(msg, "MMPROJ") {
|
||||
t.Fatalf("sans vision : %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// videoInputSupported : jamais pour une API externe, Strata, un preset sans
|
||||
// projecteur ou sans moteur ; sinon, l'aide du binaire fait foi.
|
||||
func TestVideoInputSupportedPresets(t *testing.T) {
|
||||
testHome(t)
|
||||
cas := []map[string]string{
|
||||
{"BIN": "llama-server", "MODEL": "m.gguf"}, // pas de projecteur
|
||||
{"MMPROJ": "mm.gguf", "MODEL": "m.gguf"}, // pas de moteur
|
||||
{"MMPROJ": "mm.gguf", "BIN": "introuvable/llama-server"}, // moteur absent
|
||||
{"MMPROJ": "mm.gguf", extKeyFlag: "1", extKeyVision: "1"}, // API externe
|
||||
{"MMPROJ": "mm.gguf", "ENGINE": engineStrata, "BIN": "/bin/sh", "MODEL": "m"}, // Strata
|
||||
}
|
||||
for _, cfg := range cas {
|
||||
if err := WriteConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if videoInputSupported() {
|
||||
t.Errorf("%v : vidéo annoncée", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// L'aide du binaire décide, relue quand le binaire change (mise à jour en place).
|
||||
func TestBinHelpHasVideo(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("faux moteur en sh")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "llama-server")
|
||||
write := func(help string, mt time.Time) {
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho '"+help+"'\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Chtimes(bin, mt, mt)
|
||||
}
|
||||
write("--mmproj FILE", time.Now().Add(-time.Hour))
|
||||
if binHelpHasVideo(bin) {
|
||||
t.Fatal("moteur sans --video-fps annoncé comme lisant la vidéo")
|
||||
}
|
||||
write("--video-fps N target fps", time.Now())
|
||||
if !binHelpHasVideo(bin) {
|
||||
t.Fatal("moteur mis à jour : --video-fps non relu")
|
||||
}
|
||||
}
|
||||
|
||||
// Compaction, fil rechargé, export : la vidéo n'est jamais rejouée ni
|
||||
// recopiée, mais sa perte reste visible.
|
||||
func TestVideoPerdueVisible(t *testing.T) {
|
||||
part := videoPart([]byte(strings.Repeat("v", 3000)), "video/mp4")
|
||||
m := Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "regarde"}, part}}
|
||||
if got := msgText(m); got != "regarde"+videoLostMarker {
|
||||
t.Fatalf("msgText : %q", got)
|
||||
}
|
||||
var reread []Message
|
||||
b, _ := json.Marshal([]Message{m})
|
||||
_ = json.Unmarshal(b, &reread)
|
||||
out := stripImageParts(reread)
|
||||
if s, ok := out[0].Content.(string); !ok || s != "regarde"+videoLostMarker {
|
||||
t.Fatalf("fil rechargé : %#v", out[0].Content)
|
||||
}
|
||||
exp := stripInlineImages([]Message{m})
|
||||
js, _ := json.Marshal(exp)
|
||||
if len(js) > 400 || !strings.Contains(string(js), "élidé") {
|
||||
t.Fatalf("export : %s", js)
|
||||
}
|
||||
if partVideoURL(contentParts(m.Content)[1]) == "" || !strings.HasPrefix(partVideoURL(part), "data:video/mp4;base64,") {
|
||||
t.Fatal("l'export a touché le message vivant")
|
||||
}
|
||||
}
|
||||
|
||||
// Le ménage des médias vide aussi les vidéos jointes trop vieilles.
|
||||
func TestPruneChatVideos(t *testing.T) {
|
||||
testHome(t)
|
||||
ref, err := storeChatVideo([]byte("vieille"), "video/webm")
|
||||
if err != nil || !strings.HasSuffix(ref, ".webm") {
|
||||
t.Fatalf("rangement : %q %v", ref, err)
|
||||
}
|
||||
p := filepath.Join(chatVidDir(), strings.TrimPrefix(ref, vidRefScheme))
|
||||
old := time.Now().Add(-2 * chatImgKeep)
|
||||
_ = os.Chtimes(p, old, old)
|
||||
pruneChatImages()
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatal("vieille vidéo gardée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package loki
|
||||
|
||||
// chat_video_tool.go — l'outil see_video (repris d'AJEAN) : le modèle charge
|
||||
// lui-même un fichier vidéo du disque dans sa VISION, sans que l'utilisateur ait
|
||||
// à le joindre. Même mécanique que see_image (chat_vision_tool.go) : le résultat
|
||||
// de l'outil reste un accusé texte, la vidéo part juste après dans un message
|
||||
// utilisateur multimodal (partie input_video), que llama-server décode en
|
||||
// images avec ffmpeg. Toujours ÉPHÉMÈRE : jamais dans l'historique.
|
||||
//
|
||||
// Annoncé seulement quand le moteur sait lire la vidéo (videoInputSupported) ;
|
||||
// appelé quand même, il le dit avec la marche à suivre (ffmpeg puis see_image).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// toolSeeVideo lit un fichier vidéo et renvoie (accusé texte, partie input_video).
|
||||
// La partie vaut nil en cas d'erreur : l'appelant n'injecte alors rien. Même
|
||||
// ordre de refus que toolSeeImage, la sonde du moteur en dernier.
|
||||
func toolSeeVideo(path string) (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo", nil
|
||||
}
|
||||
if agentTargetSlug() != "" {
|
||||
return "[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
|
||||
}
|
||||
if path == "" {
|
||||
return "[erreur] chemin de fichier manquant", nil
|
||||
}
|
||||
if !videoInputSupported() {
|
||||
return "[erreur] " + strings.TrimSuffix(videoFallbackNote, "\n"), nil
|
||||
}
|
||||
abs := resolveAgentPath(path)
|
||||
mime := videoMime(abs)
|
||||
if mime == "" {
|
||||
return "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : " + path, nil
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return "[erreur] fichier introuvable : " + path, nil
|
||||
}
|
||||
if st.IsDir() {
|
||||
return "[erreur] c'est un dossier, pas une vidéo : " + path, nil
|
||||
}
|
||||
if st.Size() > maxVideoBytes {
|
||||
return fmt.Sprintf("[erreur] vidéo trop lourde (%s, max %s) — coupe-la en un clip plus court : %s",
|
||||
humanBytes(st.Size()), humanBytes(maxVideoBytes), path), nil
|
||||
}
|
||||
if !engineSeesImages() {
|
||||
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "[erreur] lecture impossible : " + err.Error(), nil
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return "[erreur] fichier vide : " + path, nil
|
||||
}
|
||||
return "[ok] vidéo chargée : " + filepath.Base(abs), videoPart(b, mime)
|
||||
}
|
||||
|
||||
// seeVideoMessage emballe la partie vidéo dans le message utilisateur qui la
|
||||
// porte jusqu'au modèle ; la légende nomme le fichier (voir seeImageMessage).
|
||||
func seeVideoMessage(label string, vid map[string]any) Message {
|
||||
text := "Vidéo :"
|
||||
if label != "" {
|
||||
text = "Vidéo demandée (" + label + ") :"
|
||||
}
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": text},
|
||||
vid,
|
||||
}}
|
||||
}
|
||||
|
||||
// seeVideoTool — schéma envoyé au modèle, tenu court comme seeImageTool.
|
||||
func seeVideoTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "see_video",
|
||||
Description: "Ouvre un fichier vidéo du disque pour la REGARDER (mp4, webm, mov, mkv, avi). Clips courts.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Chemin de la vidéo (relatif au dossier de travail, ou absolu)"},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ type imageResult struct {
|
||||
|
||||
var ddgVqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`)
|
||||
|
||||
// ddgImagesBase : DuckDuckGo (un faux pour les tests).
|
||||
var ddgImagesBase = "https://duckduckgo.com"
|
||||
|
||||
func ddgGet(ctx context.Context, target, referer string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
@@ -75,7 +78,7 @@ func ddgGet(ctx context.Context, target, referer string) ([]byte, error) {
|
||||
func duckduckgoImages(query string, limit int) ([]imageResult, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout)
|
||||
defer cancel()
|
||||
page := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
|
||||
page := ddgImagesBase + "/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
|
||||
body, err := ddgGet(ctx, page, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -84,7 +87,7 @@ func duckduckgoImages(query string, limit int) ([]imageResult, error) {
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)")
|
||||
}
|
||||
api := "https://duckduckgo.com/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1])
|
||||
api := ddgImagesBase + "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1])
|
||||
body, err = ddgGet(ctx, api, "https://duckduckgo.com/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -9,6 +9,10 @@ package loki
|
||||
//
|
||||
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
|
||||
// la discussion, sa suppression les emporte.
|
||||
//
|
||||
// Port Rust de l'outil (lot 38) : rust/loki-rs/src/chat/crittool.rs, employé
|
||||
// par les tours de la façade ; journal de parité dans
|
||||
// parity_criteria_test.go.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -214,14 +218,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)
|
||||
}
|
||||
@@ -177,10 +177,25 @@ func toolBashTail(args map[string]any) string {
|
||||
return out + "\n" + tailRunes(tail, toolMaxOutput)
|
||||
}
|
||||
|
||||
// bgJobsRunning : jobs d'arrière-plan encore en vie. L'optimiseur refuse de
|
||||
// mesurer à côté d'eux : un build ou un serveur de dev prend le CPU et le
|
||||
// disque que mesurent ses essais.
|
||||
// bgJobsRunning : jobs d'arrière-plan encore en vie, ceux de la façade
|
||||
// compris quand elle tient la conversation (lot 43 : ses tours lancent les
|
||||
// leurs). L'optimiseur refuse de mesurer à côté d'eux : un build ou un
|
||||
// serveur de dev prend le CPU et le disque que mesurent ses essais.
|
||||
func bgJobsRunning() int {
|
||||
n := localBgJobsRunning()
|
||||
if facadeChat() {
|
||||
var out struct {
|
||||
Running int `json:"running"`
|
||||
}
|
||||
if err := facadePerfJSON("/internal/facade/v1/bgjobs", map[string]any{}, &out); err == nil {
|
||||
n += out.Running
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// localBgJobsRunning : les jobs lancés par ce processus.
|
||||
func localBgJobsRunning() int {
|
||||
jobsMu.Lock()
|
||||
defer jobsMu.Unlock()
|
||||
n := 0
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+75
-19
@@ -16,6 +16,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
@@ -40,39 +41,94 @@ var defaultRetryPatterns = []string{
|
||||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
||||
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
|
||||
// choisi son rôle (CLI, serveur…).
|
||||
//
|
||||
// Relus quand la surcharge change (OpenFox c52c5bf7) : un sync.Once les
|
||||
// figeait jusqu'au redémarrage du process. retryPatterns tourne pendant le flux,
|
||||
// à chaque morceau : la base n'est rouverte que si une écriture de ce process a
|
||||
// touché le bucket d'état (retryStateBust, via cacheBust) ou, au plus une fois
|
||||
// par seconde, si date ou taille du fichier trahissent l'écriture d'un autre
|
||||
// process (`loki config set` pendant que le service tourne).
|
||||
var (
|
||||
retryOnce sync.Once
|
||||
retryRes []*regexp.Regexp
|
||||
retryMu sync.Mutex
|
||||
retryRes []*regexp.Regexp
|
||||
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
|
||||
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
|
||||
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
|
||||
retryCustom bool
|
||||
retryCustom bool
|
||||
retryLoaded bool
|
||||
retryStale bool // écriture locale du bucket d'état depuis la lecture
|
||||
retryRaw string
|
||||
retryPath string // la base lue (LOKI_HOME change d'un test à l'autre)
|
||||
retryChecked time.Time
|
||||
retryMtime time.Time
|
||||
retrySize int64
|
||||
)
|
||||
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
|
||||
return retryRes
|
||||
// retryStateBust : appelé par cacheBust pour toute écriture du bucket d'état.
|
||||
func retryStateBust() {
|
||||
retryMu.Lock()
|
||||
retryStale = true
|
||||
retryMu.Unlock()
|
||||
}
|
||||
|
||||
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
|
||||
pats := defaultRetryPatterns
|
||||
custom := false
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
|
||||
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
|
||||
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
|
||||
if raw := getStr(bkState, "retry_patterns"); raw != "" {
|
||||
var over []string
|
||||
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
|
||||
pats, custom = over, true
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
res, _ := retryState()
|
||||
return res
|
||||
}
|
||||
|
||||
// retryState renvoie les motifs compilés et si ce sont ceux de la surcharge.
|
||||
func retryState() ([]*regexp.Regexp, bool) {
|
||||
retryMu.Lock()
|
||||
defer retryMu.Unlock()
|
||||
reload := !retryLoaded || retryStale || retryPath != dbPath()
|
||||
if !reload && time.Since(retryChecked) >= time.Second {
|
||||
retryChecked = time.Now()
|
||||
if mtime, size := dbStamp(); size != retrySize || !mtime.Equal(retryMtime) {
|
||||
reload = true
|
||||
}
|
||||
}
|
||||
if reload {
|
||||
raw := getStr(bkState, "retry_patterns")
|
||||
retryMtime, retrySize = dbStamp()
|
||||
retryChecked, retryStale, retryPath = time.Now(), false, dbPath()
|
||||
if !retryLoaded || raw != retryRaw {
|
||||
retryRes, retryCustom = compileRetryPatterns(raw)
|
||||
retryRaw, retryLoaded = raw, true
|
||||
}
|
||||
}
|
||||
return retryRes, retryCustom
|
||||
}
|
||||
|
||||
// compileRetryPatterns compile la surcharge raw (tableau JSON de regex), ou
|
||||
// les motifs embarqués quand elle est absente, illisible, ou sans un seul motif
|
||||
// utilisable. Un motif vide est écarté : "(?mi)" correspond à TOUT, et chaque
|
||||
// tour était relancé une fois (OpenFox 8e9b4869).
|
||||
func compileRetryPatterns(raw string) ([]*regexp.Regexp, bool) {
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns`. Pas d'UI dédiée —
|
||||
// c'est un réglage d'expert, posé via `loki config` ou l'API ; l'embarqué
|
||||
// couvre les cas connus.
|
||||
if raw != "" {
|
||||
var over []string
|
||||
if json.Unmarshal([]byte(raw), &over) == nil {
|
||||
if out := compileRetryList(over); len(out) > 0 {
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return compileRetryList(defaultRetryPatterns), false
|
||||
}
|
||||
|
||||
func compileRetryList(pats []string) []*regexp.Regexp {
|
||||
var out []*regexp.Regexp
|
||||
for _, p := range pats {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
continue
|
||||
}
|
||||
if re, err := regexp.Compile("(?mi)" + p); err == nil {
|
||||
out = append(out, re)
|
||||
}
|
||||
}
|
||||
return out, custom
|
||||
return out
|
||||
}
|
||||
|
||||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
||||
@@ -100,9 +156,9 @@ func textualToolCallFrom(content string, scanned int) bool {
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
res := retryPatterns()
|
||||
res, custom := retryState()
|
||||
start := 0
|
||||
if !retryCustom {
|
||||
if !custom {
|
||||
start = retryScanStart(content, scanned)
|
||||
}
|
||||
w := content[start:]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Un motif vide dans la surcharge correspondait à tout : chaque tour était
|
||||
// relancé une fois. Il est écarté ; une surcharge sans motif utilisable retombe
|
||||
// sur les motifs embarqués.
|
||||
func TestCompileRetryPatternsSkipsEmpty(t *testing.T) {
|
||||
res, custom := compileRetryPatterns(`["", " ", "<oups>"]`)
|
||||
if !custom || len(res) != 1 {
|
||||
t.Fatalf("custom=%v, %d motifs ; attendu la surcharge avec un seul motif", custom, len(res))
|
||||
}
|
||||
if res[0].MatchString("une réponse tout à fait normale") {
|
||||
t.Fatal("un texte ordinaire déclenche une relance")
|
||||
}
|
||||
res, custom = compileRetryPatterns(`["", "([invalide"]`)
|
||||
if custom || len(res) != len(defaultRetryPatterns) {
|
||||
t.Fatalf("custom=%v, %d motifs ; attendu les motifs embarqués", custom, len(res))
|
||||
}
|
||||
if _, custom = compileRetryPatterns(`pas du json`); custom {
|
||||
t.Fatal("surcharge illisible prise en compte")
|
||||
}
|
||||
}
|
||||
|
||||
// Un changement de retry_patterns agit sans redémarrer le process.
|
||||
func TestRetryPatternsReloadOnChange(t *testing.T) {
|
||||
testHome(t)
|
||||
retryStateBust()
|
||||
if _, custom := retryState(); custom {
|
||||
t.Fatal("surcharge active sans clé")
|
||||
}
|
||||
if err := putStr(bkState, "retry_patterns", `["OUPS-OUTIL"]`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !textualToolCall("bon, OUPS-OUTIL, voilà") {
|
||||
t.Fatal("nouveau motif ignoré après l'écriture")
|
||||
}
|
||||
if err := putStr(bkState, "retry_patterns", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, custom := retryState(); custom {
|
||||
t.Fatal("surcharge retirée mais toujours active")
|
||||
}
|
||||
if textualToolCall("bon, OUPS-OUTIL, voilà") {
|
||||
t.Fatal("ancien motif encore appliqué")
|
||||
}
|
||||
}
|
||||
@@ -90,17 +90,10 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
|
||||
return "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
|
||||
}
|
||||
|
||||
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
|
||||
// Cadrage : le rôle ne voit ni la discussion ni l'utilisateur. Son prompt de
|
||||
// rôle parle à un humain (« termine en demandant confirmation ») ; ici son
|
||||
// interlocuteur est le builder, et sa réponse est un résultat d'outil.
|
||||
framing := "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
if role == "planner" {
|
||||
framing = "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
}
|
||||
caps, system := subagentView(role)
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
|
||||
{Role: "user", Content: task + framing},
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: task + subagentFraming(role)},
|
||||
}
|
||||
var out strings.Builder
|
||||
// Le sous-agent occupe le slot de la conversation du builder : une fois son
|
||||
@@ -133,3 +126,21 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
|
||||
}
|
||||
return "[" + role + "]\n" + res
|
||||
}
|
||||
|
||||
// subagentView : les capacités et le prompt système d'un rôle délégué —
|
||||
// servis aussi à la façade (/internal/v1/turn/subagent, lot 47).
|
||||
func subagentView(role string) (Caps, string) {
|
||||
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
|
||||
return caps, rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)
|
||||
}
|
||||
|
||||
// subagentFraming : le cadrage ajouté à la tâche. Le rôle ne voit ni la
|
||||
// discussion ni l'utilisateur. Son prompt de rôle parle à un humain
|
||||
// (« termine en demandant confirmation ») ; ici son interlocuteur est le
|
||||
// builder, et sa réponse est un résultat d'outil.
|
||||
func subagentFraming(role string) string {
|
||||
if role == "planner" {
|
||||
return "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
}
|
||||
return "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -54,3 +55,31 @@ func TestSubagentRolesAreReadOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lot 47 : la façade demande au Go le contexte isolé d'un rôle délégué —
|
||||
// le même prompt système, les mêmes outils et capacités que toolSubagent.
|
||||
func TestSubagentVueInterne(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", `{"role":"planner"}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("vue : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var v struct {
|
||||
System string `json:"system"`
|
||||
Tools []Tool `json:"tools"`
|
||||
Caps Caps `json:"caps"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caps, system := subagentView("planner")
|
||||
if v.System != system || v.Caps != caps || len(v.Tools) != len(EnabledTools(caps)) {
|
||||
t.Fatalf("vue : %+v", v.Caps)
|
||||
}
|
||||
for _, body := range []string{`{"role":"verifier"}`, `{"role":""}`, `{`} {
|
||||
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", body); w.Code != 400 {
|
||||
t.Fatalf("%s : %d", body, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -457,6 +457,7 @@ func sweepStaleCUDirs() {
|
||||
}
|
||||
|
||||
func cdpShutdown() {
|
||||
facadeBrowserShutdown() // lot 44 : celui de la façade, quand elle tient la conversation
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
|
||||
@@ -169,6 +169,9 @@ func cuPromptLine() string {
|
||||
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
|
||||
|
||||
func toolCUOpen(args map[string]any) string {
|
||||
if r, _, ok := facadeBrowserTool("browser_open", args); ok {
|
||||
return r
|
||||
}
|
||||
url, _ := args["url"].(string)
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return "[erreur] url manquante"
|
||||
@@ -188,6 +191,9 @@ func toolCUOpen(args map[string]any) string {
|
||||
}
|
||||
|
||||
func toolCUSnapshot() string {
|
||||
if r, _, ok := facadeBrowserTool("browser_snapshot", map[string]any{}); ok {
|
||||
return r
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
@@ -200,6 +206,9 @@ func toolCUSnapshot() string {
|
||||
}
|
||||
|
||||
func toolCUFind(args map[string]any) string {
|
||||
if r, _, ok := facadeBrowserTool("browser_find", args); ok {
|
||||
return r
|
||||
}
|
||||
text, _ := args["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "[erreur] texte de recherche manquant"
|
||||
@@ -216,6 +225,9 @@ func toolCUFind(args map[string]any) string {
|
||||
}
|
||||
|
||||
func toolCUClick(args map[string]any) string {
|
||||
if r, _, ok := facadeBrowserTool("browser_click", args); ok {
|
||||
return r
|
||||
}
|
||||
ref, ok := intArg(args, "ref")
|
||||
if !ok {
|
||||
return "[erreur] ref manquant (numéro de l'élément)"
|
||||
@@ -232,6 +244,9 @@ func toolCUClick(args map[string]any) string {
|
||||
}
|
||||
|
||||
func toolCUType(args map[string]any) string {
|
||||
if r, _, ok := facadeBrowserTool("browser_type", args); ok {
|
||||
return r
|
||||
}
|
||||
text, _ := args["text"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
@@ -249,6 +264,9 @@ func toolCUType(args map[string]any) string {
|
||||
}
|
||||
|
||||
func toolCUKey(args map[string]any) string {
|
||||
if r, _, ok := facadeBrowserTool("browser_key", args); ok {
|
||||
return r
|
||||
}
|
||||
key, _ := args["key"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
@@ -262,6 +280,9 @@ func toolCUKey(args map[string]any) string {
|
||||
}
|
||||
|
||||
func toolCUClickXY(args map[string]any) string {
|
||||
if r, _, ok := facadeBrowserTool("browser_click_xy", args); ok {
|
||||
return r
|
||||
}
|
||||
x, okx := floatArg(args, "x")
|
||||
y, oky := floatArg(args, "y")
|
||||
if !okx || !oky {
|
||||
@@ -279,6 +300,9 @@ func toolCUClickXY(args map[string]any) string {
|
||||
}
|
||||
|
||||
func toolCUScroll(args map[string]any) string {
|
||||
if r, _, ok := facadeBrowserTool("browser_scroll", args); ok {
|
||||
return r
|
||||
}
|
||||
dir, _ := args["direction"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
@@ -299,6 +323,12 @@ func toolCUScroll(args map[string]any) string {
|
||||
// PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour
|
||||
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
|
||||
func toolCUScreenshot() (string, map[string]any) {
|
||||
if r, img, ok := facadeBrowserTool("browser_screenshot", map[string]any{}); ok {
|
||||
if len(img) == 0 {
|
||||
img = nil
|
||||
}
|
||||
return r, img
|
||||
}
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n"
|
||||
// renvoie l'identifiant de la capture en mémoire ("" si impossible : jamais
|
||||
// bloquant pour l'outil).
|
||||
func cuAutoShot() string {
|
||||
if id, ok := facadeBrowserShot(); ok {
|
||||
return id
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
cuLog("aperçu : navigateur indisponible : %v", err)
|
||||
|
||||
@@ -414,6 +414,10 @@ func asrShutdown() {
|
||||
// normale. La dictée qui attendait reçoit une erreur de démarrage — c'est
|
||||
// l'utilisateur qui vient de demander la place, pas une panne.
|
||||
func asrShutdownVite() {
|
||||
// Dictée tenue par la façade (LOKI_DICTATE=rust) : son serveur est le sien.
|
||||
if facadeDictateStop() {
|
||||
return
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if wsrvMu.TryLock() {
|
||||
asrStopLocked()
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// facade_backends.go — backends llama.cpp tenus par la façade Rust
|
||||
// (LOKI_BACKENDS=rust, lot 22 de la migration ; expérimental, le Go reste le
|
||||
// défaut) : statut, vérification git, binaires précompilés, bascule fast/opt,
|
||||
// suivi du job, backends custom et GPU vus par un moteur
|
||||
// (rust/loki-rs/src/backends).
|
||||
//
|
||||
// La façade détient alors LE job des moteurs, et compile elle-même llama.cpp
|
||||
// depuis les sources sous Linux et macOS (install, update, install-custom ;
|
||||
// lot 41, backends/build.rs). Ceux que le Go fait encore tourner — compilation
|
||||
// sous Windows (chaîne MSVC), moteur de l'image, Strata — lui rapportent
|
||||
// chaque étape (/internal/facade/v1/build-job/*) : lcAppend, lcPhase, lcFail,
|
||||
// lcDone, startLcJob et lcJobSnapshot passent par elle, et le Go ne restaure
|
||||
// plus de job au démarrage (la façade s'en charge).
|
||||
|
||||
const facadeBackendsEnv = "LOKI_BACKENDS"
|
||||
|
||||
// facadeBackends : la façade tient-elle les backends et le job ?
|
||||
func facadeBackends() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeBackendsEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
}
|
||||
|
||||
// facadeBackendsRoutes : les routes que la façade sert alors (backends/mod.rs).
|
||||
var facadeBackendsRoutes = map[string]bool{
|
||||
"/api/llamacpp": true,
|
||||
"/api/llamacpp/check": true,
|
||||
"/api/llamacpp/prebuilt": true,
|
||||
"/api/llamacpp/prebuilt/check": true,
|
||||
"/api/llamacpp/use": true,
|
||||
"/api/llamacpp/job": true,
|
||||
"/api/llamacpp/uninstall-custom": true,
|
||||
"/api/backends": true,
|
||||
"/api/backends/custom": true,
|
||||
"/api/backends/devices": true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// La compilation depuis les sources part aussi à la façade (lot 41), sauf
|
||||
// sous Windows : générateur Visual Studio et intégration CUDA de MSBuild
|
||||
// restent au Go.
|
||||
if runtime.GOOS != "windows" {
|
||||
for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} {
|
||||
facadeBackendsRoutes[p] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// facadeJobClient : appels courts (une ligne de journal, une phase).
|
||||
var facadeJobClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// facadeJobCall envoie une étape du job à la façade. out peut être nil.
|
||||
// Une réponse 409 rend l'erreur que la façade explique (job déjà en cours).
|
||||
func facadeJobCall(op string, body map[string]any, out any) error {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/build-job/"+op, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(internalTokenHeader, internalToken())
|
||||
resp, err := facadeJobClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
if e.Error == "" {
|
||||
e.Error = resp.Status
|
||||
}
|
||||
return errors.New(e.Error)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("façade : %s", resp.Status)
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// facadeJobNote : une étape dont l'échec ne doit pas arrêter le job (le
|
||||
// journal perd une ligne, rien de plus) — tracée sur la sortie d'erreur.
|
||||
func facadeJobNote(op string, body map[string]any) {
|
||||
if err := facadeJobCall(op, body, nil); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[job] étape %s non transmise à la façade : %v\n", op, err)
|
||||
}
|
||||
}
|
||||
|
||||
// facadeJobStart démarre le job côté façade, puis son corps ici.
|
||||
func facadeJobStart(action string, run func()) error {
|
||||
if err := facadeJobCall("start", map[string]any{"action": action}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
setBuildSink(lcAppend)
|
||||
go func() {
|
||||
defer func() {
|
||||
setBuildSink(nil)
|
||||
facadeJobNote("end", map[string]any{})
|
||||
}()
|
||||
run()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// facadeJobSnapshot : la vue du job tenue par la façade.
|
||||
func facadeJobSnapshot(from int, withLines bool) map[string]any {
|
||||
lines := "0"
|
||||
if withLines {
|
||||
lines = "1"
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, "http://"+facadeAddr()+
|
||||
"/internal/facade/v1/build-job/snapshot?from="+strconv.Itoa(from)+"&lines="+lines, nil)
|
||||
if err != nil {
|
||||
return map[string]any{"exists": false}
|
||||
}
|
||||
req.Header.Set(internalTokenHeader, internalToken())
|
||||
resp, err := facadeJobClient.Do(req)
|
||||
if err != nil {
|
||||
return map[string]any{"exists": false}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil {
|
||||
return map[string]any{"exists": false}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lcSetCommit note le commit d'avant ("old") ou d'après ("new") une mise à
|
||||
// jour.
|
||||
func lcSetCommit(which, commit string) {
|
||||
if facadeBackends() {
|
||||
facadeJobNote("commits", map[string]any{which: commit})
|
||||
return
|
||||
}
|
||||
lcMu.Lock()
|
||||
if lcCur != nil {
|
||||
if which == "old" {
|
||||
lcCur.OldCommit = commit
|
||||
} else {
|
||||
lcCur.NewCommit = commit
|
||||
}
|
||||
}
|
||||
lcMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package loki
|
||||
|
||||
import "time"
|
||||
|
||||
// resetBackendsState : pas de job ni de GPU en cache d'un test à l'autre.
|
||||
func resetBackendsState() {
|
||||
lcMu.Lock()
|
||||
lcCur = nil
|
||||
lcLastSave = time.Time{}
|
||||
lcMu.Unlock()
|
||||
setBuildSink(nil)
|
||||
devCacheMu.Lock()
|
||||
devCache = map[string]devCacheEntry{}
|
||||
devCacheMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// facade_bench.go — le banc d'essai tenu par la façade Rust (LOKI_BENCH=rust,
|
||||
// lot 27 de la migration ; expérimental, le Go reste le défaut) : les routes
|
||||
// /api/bench*, le job et le protocole de mesure (rust/loki-rs/src/bench). Le
|
||||
// verrou de génération reste celui de la conversation du Go : la façade le
|
||||
// prend par le bail du moteur, avec le motif « bench » (benching, et le refus
|
||||
// errBenchBusy pour les autres). Le Go lui prête aussi l'isolation des travaux
|
||||
// annexes (effacement du slot à la fin) et la télémétrie (perf_log.go).
|
||||
|
||||
const facadeBenchEnv = "LOKI_BENCH"
|
||||
|
||||
// facadeBench : la façade tient-elle le banc d'essai ?
|
||||
func facadeBench() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeBenchEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
}
|
||||
|
||||
// facadeBenchRoutes : les routes que la façade sert alors.
|
||||
var facadeBenchRoutes = map[string]bool{
|
||||
"/api/bench": true,
|
||||
"/api/bench/status": true,
|
||||
"/api/bench/cancel": true,
|
||||
"/api/bench/last": true,
|
||||
}
|
||||
|
||||
// facadeSideJobs : les travaux annexes ouverts par la façade (id → fin).
|
||||
var facadeSideJobs = struct {
|
||||
mu sync.Mutex
|
||||
seq int
|
||||
m map[int]func()
|
||||
}{m: map[int]func(){}}
|
||||
|
||||
// handleInternalSideJob : POST {} ouvre un travail annexe (engineSideJob) et
|
||||
// rend son id ; POST {"end": id, "served": bool} le ferme — served : le
|
||||
// moteur a servi au moins une requête du travail (engineServed), le slot
|
||||
// porte donc son état et peut être effacé.
|
||||
func handleInternalSideJob(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
End int `json:"end"`
|
||||
Served bool `json:"served"`
|
||||
}
|
||||
if !readLeaseBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.End == 0 {
|
||||
done := engineSideJob()
|
||||
facadeSideJobs.mu.Lock()
|
||||
facadeSideJobs.seq++
|
||||
id := facadeSideJobs.seq
|
||||
facadeSideJobs.m[id] = done
|
||||
facadeSideJobs.mu.Unlock()
|
||||
sendJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||
return
|
||||
}
|
||||
facadeSideJobs.mu.Lock()
|
||||
done := facadeSideJobs.m[in.End]
|
||||
delete(facadeSideJobs.m, in.End)
|
||||
facadeSideJobs.mu.Unlock()
|
||||
if done == nil {
|
||||
sendJSON(w, http.StatusNotFound, map[string]any{"error": "travail annexe inconnu"})
|
||||
return
|
||||
}
|
||||
if in.Served {
|
||||
engineServed()
|
||||
}
|
||||
done()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleInternalBenchPerf : la trace d'un bench de la façade dans la
|
||||
// télémétrie (le bench a pris le slot : la perte de cache du tour suivant lui
|
||||
// revient).
|
||||
func handleInternalBenchPerf(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
PromptN int `json:"prompt_n"`
|
||||
PromptMs float64 `json:"prompt_ms"`
|
||||
PromptPerSecond float64 `json:"prompt_per_second"`
|
||||
PredictedN int `json:"predicted_n"`
|
||||
PredictedPerSec float64 `json:"predicted_per_second"`
|
||||
}
|
||||
if !readLeaseBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
perfRecord(perfRec{Kind: perfBench, Complete: true, Total: in.PromptN, New: in.PromptN,
|
||||
PPms: in.PromptMs, PPtps: in.PromptPerSecond, Gen: in.PredictedN, TGtps: in.PredictedPerSec}, nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Le banc d'essai part à la façade avec LOKI_BENCH=rust (lot 27) ; le reste,
|
||||
// non. La façade tient le verrou de génération par le bail du moteur, motif
|
||||
// « bench », et le Go lui prête le travail annexe et la télémétrie.
|
||||
func TestFacadeBench(t *testing.T) {
|
||||
testHome(t)
|
||||
var mu sync.Mutex
|
||||
var calls []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(internalTokenHeader) != jetonTest {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
mu.Unlock()
|
||||
io.WriteString(w, `{"ok":true,"de":"facade"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
|
||||
t.Setenv(facadeBenchEnv, "")
|
||||
if facadeBench() {
|
||||
t.Fatal("tenu sans LOKI_BENCH")
|
||||
}
|
||||
for p := range facadeBenchRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
||||
if w.Body.String() != "go" {
|
||||
t.Fatalf("sans LOKI_BENCH, %s : %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
t.Setenv(facadeBenchEnv, " RUST")
|
||||
if len(facadeBenchRoutes) != 4 {
|
||||
t.Fatalf("routes : %v", facadeBenchRoutes)
|
||||
}
|
||||
for p := range facadeBenchRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
||||
if !strings.Contains(w.Body.String(), "facade") {
|
||||
t.Fatalf("%s non relayé : %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
n := len(calls)
|
||||
mu.Unlock()
|
||||
if n != len(facadeBenchRoutes) {
|
||||
t.Fatalf("relais : %v", calls)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat("/api/tune", local)(w, httptest.NewRequest("POST", "/api/tune", nil))
|
||||
if w.Body.String() != "go" {
|
||||
t.Fatalf("l'optimiseur reste au Go : %q", w.Body.String())
|
||||
}
|
||||
|
||||
// Le bail « bench » : le verrou, le refus du bench pour les autres.
|
||||
post := func(h http.HandlerFunc, path, body string) (int, map[string]any) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{"ttl_ms":60000,"why":"bench"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("bail : %d %v", code, out)
|
||||
}
|
||||
id, _ := out["lease"].(string)
|
||||
if !benchRunning() || conv.busyReason() != errBenchBusy {
|
||||
t.Fatalf("bail du bench : running=%v refus=%v", benchRunning(), conv.busyReason())
|
||||
}
|
||||
if code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{}`); code != 409 || out["error"] != errBenchBusy.Error() {
|
||||
t.Fatalf("second bail : %d %v", code, out)
|
||||
}
|
||||
// Changer de discussion ne rompt pas le bail d'un bench.
|
||||
conv.loadFrom("autre", nil)
|
||||
if !benchRunning() || !conv.isGenerating() {
|
||||
t.Fatal("le bail du bench a sauté à la bascule de discussion")
|
||||
}
|
||||
if code, _ := post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+id+`"}`); code != 204 {
|
||||
t.Fatalf("libération : %d", code)
|
||||
}
|
||||
if benchRunning() || conv.isGenerating() || conv.busyReason() == errBenchBusy {
|
||||
t.Fatal("bail rendu : le verrou devait être libre")
|
||||
}
|
||||
// Un bail ordinaire n'est pas un bench ; un bail de bench expiré rend
|
||||
// le verrou et le refus.
|
||||
code, out = post(handleInternalLease, "/internal/v1/engine/lease", `{}`)
|
||||
if code != 200 || benchRunning() {
|
||||
t.Fatalf("bail ordinaire : %d %v bench=%v", code, out, benchRunning())
|
||||
}
|
||||
post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+out["lease"].(string)+`"}`)
|
||||
conv.mu.Lock()
|
||||
conv.extLeaseClearLocked()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
if _, err := conv.extLeaseAcquire(5*time.Second, errBenchBusy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv.mu.Lock()
|
||||
st := conv.extLease
|
||||
conv.mu.Unlock()
|
||||
conv.extLeaseExpire(st)
|
||||
if benchRunning() || conv.busyReason() == errBenchBusy {
|
||||
t.Fatal("bail de bench expiré : refus du bench encore là")
|
||||
}
|
||||
|
||||
// Le travail annexe ouvert puis fermé par la façade.
|
||||
code, out = post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{}`)
|
||||
job, _ := out["id"].(float64)
|
||||
if code != 200 || job <= 0 || sideJobs.Load() != 1 {
|
||||
t.Fatalf("ouverture : %d %v (en cours : %d)", code, out, sideJobs.Load())
|
||||
}
|
||||
served := engineServedCount()
|
||||
if code, _ := post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{"end":`+strconv.Itoa(int(job))+`,"served":true}`); code != 204 {
|
||||
t.Fatalf("fermeture : %d", code)
|
||||
}
|
||||
if sideJobs.Load() != 0 || engineServedCount() != served+1 {
|
||||
t.Fatalf("après la fermeture : en cours %d, servies %d", sideJobs.Load(), engineServedCount()-served)
|
||||
}
|
||||
if code, _ := post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{"end":999}`); code != 404 {
|
||||
t.Fatalf("travail inconnu : %d", code)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handleInternalSideJob(rec, httptest.NewRequest("GET", "/internal/v1/engine/sidejob", nil))
|
||||
if rec.Code != 405 {
|
||||
t.Fatalf("GET : %d", rec.Code)
|
||||
}
|
||||
|
||||
// La trace du bench dans la télémétrie.
|
||||
var before uint64
|
||||
if recs := perfLog.snapshot(); len(recs) > 0 {
|
||||
before = recs[len(recs)-1].Seq
|
||||
}
|
||||
if code, _ := post(handleInternalBenchPerf, "/internal/v1/bench/perf",
|
||||
`{"prompt_n":2036,"prompt_ms":1018,"prompt_per_second":2000,"predicted_n":300,"predicted_per_second":50}`); code != 204 {
|
||||
t.Fatalf("trace : %d", code)
|
||||
}
|
||||
recs := perfLog.snapshot()
|
||||
if len(recs) == 0 || recs[len(recs)-1].Seq != before+1 {
|
||||
t.Fatalf("trace absente (dernière avant : %d)", before)
|
||||
}
|
||||
r := recs[len(recs)-1]
|
||||
if r.Kind != perfBench || !r.Complete || r.Total != 2036 || r.New != 2036 || r.Gen != 300 || r.TGtps != 50 || r.PPtps != 2000 || r.PPms != 1018 {
|
||||
t.Fatalf("trace : %+v", r)
|
||||
}
|
||||
|
||||
t.Setenv(internalTokenEnv, "")
|
||||
if facadeBench() {
|
||||
t.Fatal("tenu sans jeton interne")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 475 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user