mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6ea661d705 | ||
|
|
e236069c92 | ||
|
|
6afe07a823 | ||
|
|
30ce3a4e86 | ||
|
|
a463ccbd9f | ||
|
|
393004cb06 | ||
|
|
7d614324a0 | ||
|
|
853e59c085 | ||
|
|
e7fb63b1dd | ||
|
|
6aaf9360b4 | ||
|
|
c2a2cc5748 | ||
|
|
ac6d8ad144 | ||
|
|
5016396998 |
No files matched your search
@@ -8,3 +8,4 @@ dist/
|
|||||||
!README.md
|
!README.md
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
rust/target/
|
||||||
@@ -31,6 +31,24 @@ jobs:
|
|||||||
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
|
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
|
||||||
done
|
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:
|
macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
LOKI_VERSION=${{ github.sha }}
|
LOKI_VERSION=${{ github.sha }}
|
||||||
|
LOKI_BUILD=${{ github.run_number }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
|
# 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.
|
# pas : seul le code Go et l'assemblage de l'image se refont.
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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/
|
.pytest_cache/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
node_modules/
|
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.
|
# limité à cette étape de build.
|
||||||
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
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) ──────────
|
# ── É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
|
# 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
|
# 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/loki /usr/local/bin/loki
|
||||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
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
|
# 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/.
|
# 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
|
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_VISIBLE_DEVICES=all \
|
||||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
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
|
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
|
||||||
# conteneur — il n'est pas authentifié par défaut.
|
# conteneur — il n'est pas authentifié par défaut.
|
||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ expose à la place un export/import de paquet chiffré en fichier local.
|
|||||||
|
|
||||||
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
|
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
|
||||||
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
|
**[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
|
`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
|
release `moe-v1.1` (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
|
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
|
||||||
inclus dans l'image : ils sont téléchargés à la demande.
|
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
|
## Services externes
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ seule image.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────── conteneur loki ──────────────────┐
|
┌────────────────── 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) │
|
│ │ pilote (fichier PID — pas de systemd) │
|
||||||
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
||||||
│ ▲ modèles .gguf │
|
│ ▲ modèles .gguf │
|
||||||
@@ -35,6 +37,13 @@ seule image.
|
|||||||
redémarre le moteur normalement.
|
redémarre le moteur normalement.
|
||||||
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
|
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
|
||||||
seule l'UI (8090) l'est.
|
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)
|
## 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
|
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
|
||||||
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
|
**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.
|
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).
|
SHA-256).
|
||||||
|
|
||||||
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
|
**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 :
|
`/data/strata`). Le modèle installé devient un preset comme les autres :
|
||||||
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
|
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
|
||||||
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
|
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
|
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
|
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
|
||||||
@@ -120,6 +148,43 @@ verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
|
|||||||
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
|
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
|
||||||
llama.cpp.
|
llama.cpp.
|
||||||
|
|
||||||
|
### Strata sur un SSD dédié (Unraid)
|
||||||
|
|
||||||
|
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
|
||||||
|
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
|
||||||
|
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
|
||||||
|
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
|
||||||
|
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
|
||||||
|
et un disque dur de l'array est à proscrire.
|
||||||
|
|
||||||
|
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
|
||||||
|
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
|
||||||
|
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
|
||||||
|
Le SSD est alors monté en `/mnt/strata`.
|
||||||
|
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
|
||||||
|
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
|
||||||
|
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
|
||||||
|
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
|
||||||
|
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
|
||||||
|
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
|
||||||
|
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
|
||||||
|
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
|
||||||
|
les presets restent valides : le chemin vu par le conteneur ne change pas),
|
||||||
|
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
|
||||||
|
montage et occupe de la place pour rien.
|
||||||
|
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
|
||||||
|
```yaml
|
||||||
|
- /mnt/strata/loki:/data/strata
|
||||||
|
```
|
||||||
|
(sous les deux volumes existants), puis *Compose Up*.
|
||||||
|
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
|
||||||
|
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
|
||||||
|
proposées, puis installe.
|
||||||
|
|
||||||
|
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
|
||||||
|
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
|
||||||
|
ou cache : le pool `strata` n'est lié à aucun partage.
|
||||||
|
|
||||||
## Installation sur Unraid
|
## Installation sur Unraid
|
||||||
|
|
||||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||||
@@ -135,6 +200,32 @@ Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose
|
|||||||
`docker-compose.unraid.yml` → **Compose Up**.
|
`docker-compose.unraid.yml` → **Compose Up**.
|
||||||
4. Interface : `http://<ip-unraid>:8090`.
|
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
|
## Configuration
|
||||||
|
|
||||||
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
|
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
|
||||||
@@ -152,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_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_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_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
|
**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
|
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
|
||||||
@@ -1025,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 |
|
| 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 |
|
| 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 |
|
| 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.
|
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
|
||||||
Pour récupérer les évolutions de l'amont :
|
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é),
|
# 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.
|
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
|
||||||
#
|
#
|
||||||
# Mise à jour : Compose Down puis Up avec « pull image », ou
|
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
|
||||||
# « docker compose pull » avant Up.
|
# 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:
|
services:
|
||||||
loki:
|
loki:
|
||||||
@@ -39,6 +41,9 @@ services:
|
|||||||
# sinon l'API répond 403 (protection contre les sites tiers / DNS
|
# sinon l'API répond 403 (protection contre les sites tiers / DNS
|
||||||
# rebinding). Inutile pour un accès par IP ou nom local.
|
# rebinding). Inutile pour un accès par IP ou nom local.
|
||||||
- LOKI_TRUSTED_HOSTS=
|
- 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:
|
volumes:
|
||||||
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
|
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
|
||||||
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
|
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
|
||||||
@@ -64,6 +69,15 @@ services:
|
|||||||
- /mnt/user/appdata/loki/data:/data
|
- /mnt/user/appdata/loki/data:/data
|
||||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||||
- /mnt/user/appdata/loki/models:/models
|
- /mnt/user/appdata/loki/models:/models
|
||||||
|
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
|
||||||
|
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
|
||||||
|
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
|
||||||
|
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
|
||||||
|
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
|
||||||
|
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
|
||||||
|
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
|
||||||
|
# SSD dédié ».
|
||||||
|
# - /mnt/strata/loki:/data/strata
|
||||||
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
|
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
|
||||||
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||||
ulimits:
|
ulimits:
|
||||||
|
|||||||
+39
-3
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
|
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
|
||||||
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
|
# 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
|
set -eu
|
||||||
|
|
||||||
: "${LOKI_HOME:=/data}"
|
: "${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."
|
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
|
||||||
fi
|
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
|
# 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,
|
# 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.
|
# 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"
|
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
|
||||||
fi
|
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.
|
||||||
@@ -44,20 +44,45 @@ func emitBuildLine(line string) {
|
|||||||
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
|
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
|
||||||
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
|
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
|
||||||
type sinkWriter struct {
|
type sinkWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buf []byte
|
buf []byte
|
||||||
|
lastProg time.Time // dernière ligne de progression (\r) émise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sinkProgressEvery : une barre de progression (tqdm, téléchargements de
|
||||||
|
// l'installeur de Strata) se redessine sur place avec \r, sans jamais finir sa
|
||||||
|
// ligne. On en montre une version toutes les 10 s : sans elle, un téléchargement
|
||||||
|
// de 76 Go laissait le journal figé pendant une heure ; toutes, elles le noieraient.
|
||||||
|
const sinkProgressEvery = 10 * time.Second
|
||||||
|
|
||||||
func (s *sinkWriter) Write(p []byte) (int, error) {
|
func (s *sinkWriter) Write(p []byte) (int, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.buf = append(s.buf, p...)
|
s.buf = append(s.buf, p...)
|
||||||
for {
|
for {
|
||||||
i := strings.IndexByte(string(s.buf), '\n')
|
i := strings.IndexAny(string(s.buf), "\r\n")
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
|
if s.buf[i] == '\n' {
|
||||||
|
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
|
||||||
|
s.buf = s.buf[i+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// \r : fin de ligne Windows (\r\n) ou barre qui se redessine. Le \n peut
|
||||||
|
// arriver dans l'écriture suivante : on attend de le savoir.
|
||||||
|
if i+1 >= len(s.buf) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if s.buf[i+1] == '\n' {
|
||||||
|
emitBuildLine(string(s.buf[:i]))
|
||||||
|
s.buf = s.buf[i+2:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prog := strings.TrimSpace(string(s.buf[:i])); prog != "" && time.Since(s.lastProg) >= sinkProgressEvery {
|
||||||
|
s.lastProg = time.Now()
|
||||||
|
emitBuildLine(prog)
|
||||||
|
}
|
||||||
s.buf = s.buf[i+1:]
|
s.buf = s.buf[i+1:]
|
||||||
}
|
}
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Une barre de progression (\r sans \n) doit apparaître dans le journal, sans
|
||||||
|
// le noyer ; \r\n reste une fin de ligne ordinaire, même coupé entre deux écritures.
|
||||||
|
func TestSinkWriterProgression(t *testing.T) {
|
||||||
|
var got []string
|
||||||
|
setBuildSink(func(l string) { got = append(got, l) })
|
||||||
|
defer setBuildSink(nil)
|
||||||
|
|
||||||
|
s := &sinkWriter{}
|
||||||
|
s.Write([]byte("Step 5\r"))
|
||||||
|
s.Write([]byte("\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r"))
|
||||||
|
s.Write([]byte(" modèle : 3 / 76 GB\r"))
|
||||||
|
s.lastProg = time.Now().Add(-time.Minute) // 10 s écoulées
|
||||||
|
s.Write([]byte(" modèle : 40 / 76 GB\r[ok] fini\n"))
|
||||||
|
|
||||||
|
// « 3 » finissait sa propre écriture : il attendait de savoir si un saut de
|
||||||
|
// ligne suivait, et part donc avec l'écriture suivante.
|
||||||
|
want := []string{"Step 5", "modèle : 1 / 76 GB", "modèle : 3 / 76 GB", "[ok] fini"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("lignes %q, attendu %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,11 +73,16 @@ func writeAPIKey(key string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4
|
// 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 {
|
func maskAPIKey(k string) string {
|
||||||
if k == "" {
|
if k == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if len(k) <= 2 {
|
||||||
|
return "…"
|
||||||
|
}
|
||||||
if len(k) <= 13 {
|
if len(k) <= 13 {
|
||||||
return "…" + k[len(k)-2:]
|
return "…" + k[len(k)-2:]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,10 +282,96 @@ type dlState struct {
|
|||||||
Part int `json:"part"` // tranche en cours (1-based)
|
Part int `json:"part"` // tranche en cours (1-based)
|
||||||
Finished bool `json:"finished"`
|
Finished bool `json:"finished"`
|
||||||
Canceled bool `json:"canceled"`
|
Canceled bool `json:"canceled"`
|
||||||
|
Paused bool `json:"paused"`
|
||||||
Err string `json:"error"`
|
Err string `json:"error"`
|
||||||
StartedAt int64 `json:"started_at"`
|
StartedAt int64 `json:"started_at"`
|
||||||
|
|
||||||
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
|
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 (
|
var (
|
||||||
@@ -429,7 +515,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
st := &dlState{
|
st := &dlState{
|
||||||
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
|
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
|
dlDownloads[name] = st
|
||||||
dlMu.Unlock()
|
dlMu.Unlock()
|
||||||
@@ -759,7 +845,9 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
|
|||||||
dt := now.Sub(lastAt).Seconds()
|
dt := now.Sub(lastAt).Seconds()
|
||||||
dlMu.Lock()
|
dlMu.Lock()
|
||||||
st.Done = cur
|
st.Done = cur
|
||||||
if dt > 0 {
|
if st.Paused {
|
||||||
|
st.Speed = 0
|
||||||
|
} else if dt > 0 {
|
||||||
inst := int64(float64(cur-last) / dt)
|
inst := int64(float64(cur-last) / dt)
|
||||||
if st.Speed == 0 {
|
if st.Speed == 0 {
|
||||||
st.Speed = inst
|
st.Speed = inst
|
||||||
@@ -827,7 +915,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
|||||||
return err
|
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()
|
f.Close()
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
return err
|
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`
|
// 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.
|
// 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 {
|
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)
|
size := total / int64(conns)
|
||||||
var wg sync.WaitGroup
|
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)
|
wg.Add(1)
|
||||||
go func(i int, start, end int64) {
|
go func(i int, start, end int64) {
|
||||||
defer wg.Done()
|
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)
|
}(i, start, end)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
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
|
// 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
|
// it stopped if the connection drops mid-chunk. With whole=true it streams the
|
||||||
// entire body sequentially (server without range support, unknown size).
|
// 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
|
const attempts = 4
|
||||||
pos := start
|
pos := start
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for try := 0; try < attempts; try++ {
|
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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return err // cancelled: never retry
|
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 {
|
} else if pos > start {
|
||||||
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
|
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 {
|
if err != nil {
|
||||||
|
release()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resp, err := dlClient.Do(req)
|
resp, err := dlClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
release()
|
||||||
|
if g.isPaused() && ctx.Err() == nil {
|
||||||
|
try--
|
||||||
|
continue
|
||||||
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||||
err := dlSourceError(resp, dlURL)
|
err := dlSourceError(resp, dlURL)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
release()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resp.StatusCode == 200 && pos > start {
|
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)
|
n, cerr := dlCopy(f, resp.Body, pos, done)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
release()
|
||||||
pos += n
|
pos += n
|
||||||
if cerr == nil {
|
if cerr == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if g.isPaused() && ctx.Err() == nil {
|
||||||
|
try-- // coupé par la pause : la reprise repart de pos
|
||||||
|
continue
|
||||||
|
}
|
||||||
lastErr = cerr
|
lastErr = cerr
|
||||||
}
|
}
|
||||||
return lastErr
|
return lastErr
|
||||||
@@ -981,6 +1086,35 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 200, map[string]any{"ok": true})
|
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
|
// 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
|
// 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
|
// (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)
|
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"
|
"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 {
|
type ghAsset struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -41,18 +42,25 @@ func prebuiltDir() string {
|
|||||||
return filepath.Join(backendsDir(), "llama.cpp-prebuilt")
|
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"
|
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
|
||||||
// (cudaVer vide hors CUDA Windows).
|
// (cudaVer vide hors CUDA Windows).
|
||||||
func prebuiltVersion() (tag, cudaVer string) {
|
func prebuiltVersion() (tag, cudaVer string) {
|
||||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
f := prebuiltVersionFields()
|
||||||
if err != nil {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
f := strings.Fields(strings.TrimSpace(string(b)))
|
|
||||||
if len(f) > 0 {
|
if len(f) > 0 {
|
||||||
tag = f[0]
|
tag = f[0]
|
||||||
}
|
}
|
||||||
if len(f) > 1 {
|
if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") {
|
||||||
cudaVer = f[1]
|
cudaVer = f[1]
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -64,15 +72,13 @@ func prebuiltVersion() (tag, cudaVer string) {
|
|||||||
// sera refaite au lieu d'être déclarée « déjà à jour ».
|
// sera refaite au lieu d'être déclarée « déjà à jour ».
|
||||||
const prebuiltFormat = "fmt2"
|
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 {
|
func prebuiltVersionFormat() string {
|
||||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
f := prebuiltVersionFields()
|
||||||
if err != nil {
|
if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") {
|
||||||
return ""
|
return f[len(f)-1]
|
||||||
}
|
|
||||||
f := strings.Fields(strings.TrimSpace(string(b)))
|
|
||||||
if len(f) > 2 {
|
|
||||||
return f[2]
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -527,6 +527,7 @@ type serveSysInfo struct {
|
|||||||
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
|
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
|
||||||
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
||||||
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
|
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
|
||||||
|
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
|
||||||
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
|
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
|
||||||
|
|
||||||
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
si.Draft = p
|
si.Draft = p
|
||||||
|
if a, err := os.Stat(p); err == nil {
|
||||||
|
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
|
||||||
|
si.DraftIsModel = true
|
||||||
|
}
|
||||||
|
}
|
||||||
if g, err := ggufMeta(p); err == nil {
|
if g, err := ggufMeta(p); err == nil {
|
||||||
si.DraftGGUF = &g
|
si.DraftGGUF = &g
|
||||||
}
|
}
|
||||||
@@ -241,6 +246,23 @@ func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) str
|
|||||||
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
|
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
|
||||||
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
|
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
|
||||||
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
|
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
|
||||||
|
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
|
||||||
|
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
|
||||||
|
// (common_speculative_init_result charge tout fichier -md comme un modèle
|
||||||
|
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
|
||||||
|
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
|
||||||
|
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
|
||||||
|
// lieu de 22. On l'ignore : même tête, sans les poids en double.
|
||||||
|
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
|
||||||
|
c := make(map[string]string, len(cfg))
|
||||||
|
for k, v := range cfg {
|
||||||
|
c[k] = v
|
||||||
|
}
|
||||||
|
delete(c, "MODEL_DRAFT")
|
||||||
|
args, notes, auto = specArgs(c, extra, si)
|
||||||
|
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
|
||||||
|
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
|
||||||
|
}
|
||||||
mode := specMode(cfg)
|
mode := specMode(cfg)
|
||||||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||||
switch mode {
|
switch mode {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package loki
|
package loki
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
|
|||||||
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
||||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
||||||
}},
|
}},
|
||||||
|
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
|
||||||
|
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
|
||||||
|
si: func(s *serveSysInfo) {
|
||||||
|
s.Draft = s.Model
|
||||||
|
s.DraftGGUF = s.GGUF
|
||||||
|
s.DraftIsModel = true
|
||||||
|
}},
|
||||||
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
|
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
|
||||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
|
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
|
||||||
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
|
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
|
||||||
@@ -493,3 +501,26 @@ func TestSynthAcceptanceWarned(t *testing.T) {
|
|||||||
t.Errorf("variable : %q", n)
|
t.Errorf("variable : %q", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
|
||||||
|
func TestProbeSpecDraftIsModel(t *testing.T) {
|
||||||
|
t.Setenv("LOKI_HOME", t.TempDir())
|
||||||
|
dir := t.TempDir()
|
||||||
|
model := dir + "/Swift-27B.gguf"
|
||||||
|
other := dir + "/mtp-Swift.gguf"
|
||||||
|
for _, p := range []string{model, other} {
|
||||||
|
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
si := serveSysInfo{Model: model}
|
||||||
|
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
|
||||||
|
if !si.DraftIsModel {
|
||||||
|
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
|
||||||
|
}
|
||||||
|
si = serveSysInfo{Model: model}
|
||||||
|
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
|
||||||
|
if si.DraftIsModel || si.Draft != other {
|
||||||
|
t.Errorf("tête à part prise pour le modèle : %+v", si)
|
||||||
|
}
|
||||||
|
}
|
||||||
+480
-104
@@ -5,11 +5,13 @@ package loki
|
|||||||
// répartit les experts entre VRAM, RAM et disque pour faire tenir le modèle sur
|
// répartit les experts entre VRAM, RAM et disque pour faire tenir le modèle sur
|
||||||
// une carte de joueur.
|
// une carte de joueur.
|
||||||
//
|
//
|
||||||
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.0 ») : le moteur est le paquet
|
// 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.0 — sources de Strata et
|
// 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
|
// 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
|
// modifications : experts de la carte d'appoint gardés hors RAM et, depuis la
|
||||||
// fichiers sont vérifiés par SHA-256 avant usage.
|
// 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
|
// Le choix du moteur se fait par PRESET : ENGINE=strata. Sans la clé (ou
|
||||||
// ENGINE=llamacpp), rien ne change, llama-server reste le moteur.
|
// ENGINE=llamacpp), rien ne change, llama-server reste le moteur.
|
||||||
@@ -47,22 +49,34 @@ import (
|
|||||||
"os/exec"
|
"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
|
// 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 (
|
const (
|
||||||
strataVersion = "1.0"
|
strataVersion = "1.1"
|
||||||
strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/"
|
|
||||||
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
|
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
|
||||||
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
|
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
|
||||||
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
|
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
|
||||||
strataEngineLocal = "strata-linux-x64-cuda12.zip"
|
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{
|
var strataAssetSHA = map[string]string{
|
||||||
strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b",
|
strataSrcAsset: "5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115",
|
||||||
strataEngineAsset: "b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c",
|
strataEngineAsset: "8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valeurs de la clé ENGINE d'un preset.
|
// Valeurs de la clé ENGINE d'un preset.
|
||||||
@@ -122,16 +136,135 @@ const strataLowRAMHeadroomGB = 10
|
|||||||
|
|
||||||
// strataDropHeadroomGB : RAM à laisser à côté des experts verrouillés quand la
|
// 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,
|
// 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).
|
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres ; le moteur 1.1 prend
|
||||||
const strataDropHeadroomGB = 7
|
// ~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
|
// 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).
|
// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go).
|
||||||
func strataHelperExpertsGB(vramGB float64) float64 { return max(vramGB-2.5, 0) }
|
func strataHelperExpertsGB(vramGB float64) float64 { return max(vramGB-2.5, 0) }
|
||||||
|
|
||||||
// strataMmapExtraGB : le mode mmap écrit les experts dans un fichier à part
|
// Où vivent les experts que les cartes ne gardent pas (clé STRATA_EXPERTS du
|
||||||
// (experts.bin), à compter en plus du téléchargement.
|
// preset : auto, ram ou disk), et le mode effectivement lancé :
|
||||||
const strataMmapExtraGB = 45
|
//
|
||||||
|
// drop experts de la carte d'appoint hors RAM, le reste verrouillé en RAM
|
||||||
|
// (le plus rapide mesuré par AJEAN) — demande un experts.bin existant
|
||||||
|
// ram tous les experts chargés et verrouillés en RAM
|
||||||
|
// arena experts.bin existant mappé tel quel (cache système)
|
||||||
|
// disk experts lus dans les fichiers du modèle (--mmap-experts) : rien
|
||||||
|
// n'est chargé au démarrage, le cache système garde ce que la RAM
|
||||||
|
// permet. Un peu plus lent, mais jamais tué faute de RAM.
|
||||||
|
//
|
||||||
|
// Vécu (2026-10) : 62 Go de RAM dont 18 déjà pris par le reste du serveur, le
|
||||||
|
// mode « hors RAM » choisi sur la RAM TOTALE, et un premier lancement qui
|
||||||
|
// écrivait experts.bin en chargeant ~41 Go en mémoire anonyme : le noyau a tué
|
||||||
|
// le moteur. D'où : décision sur la RAM DISPONIBLE au lancement, et plus jamais
|
||||||
|
// de premier lancement qui écrit experts.bin.
|
||||||
|
const (
|
||||||
|
strataExpertsAuto = "auto"
|
||||||
|
strataExpertsRAM = "ram"
|
||||||
|
strataExpertsDisk = "disk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(meminfoPath)
|
||||||
|
if err != nil {
|
||||||
|
return totalRAMGB()
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "MemAvailable:"); ok {
|
||||||
|
f := strings.Fields(v)
|
||||||
|
if len(f) > 0 {
|
||||||
|
if kb, err := strconv.ParseFloat(f[0], 64); err == nil {
|
||||||
|
return kb * 1024 / 1e9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalRAMGB()
|
||||||
|
}
|
||||||
|
|
||||||
|
// strataArenaGB : la taille des experts du quant d'un preset (lue dans le nom
|
||||||
|
// de sa config d'installeur : strata-swift-iq3_xxs.json → IQ3_XXS). 0 = inconnu.
|
||||||
|
func strataArenaGB(cfg map[string]string) float64 {
|
||||||
|
tag := strings.ToUpper(strataConfigTag(strings.TrimSpace(cfg["STRATA_CONFIG"])))
|
||||||
|
tag = strings.TrimPrefix(tag, "SWIFT-")
|
||||||
|
if q, ok := strataQuants[tag]; ok {
|
||||||
|
return q.ArenaGB
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// strataPackHasExpertsBin : experts.bin est déjà dans le pack (écrit par un
|
||||||
|
// lancement précédent ou par l'installeur en mode peu de RAM).
|
||||||
|
func strataPackHasExpertsBin(args []string) bool {
|
||||||
|
for i := 0; i+1 < len(args); i++ {
|
||||||
|
if args[i] == "--pack" {
|
||||||
|
if _, err := os.Stat(filepath.Join(args[i+1], "experts.bin")); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// strataResolveExperts : le mode lancé, d'après le choix du preset et la RAM
|
||||||
|
// disponible (availGB) ; helperGB = VRAM de la carte d'appoint (0 sans).
|
||||||
|
// 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
|
||||||
|
// (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:
|
||||||
|
if binReady {
|
||||||
|
return "arena"
|
||||||
|
}
|
||||||
|
return "disk"
|
||||||
|
case strataExpertsRAM:
|
||||||
|
if dropReady {
|
||||||
|
return "drop"
|
||||||
|
}
|
||||||
|
return "ram"
|
||||||
|
}
|
||||||
|
if arena <= 0 { // quant inconnu : on ne parie pas sur la RAM
|
||||||
|
return "disk"
|
||||||
|
}
|
||||||
|
if dropReady && availGB >= arena-strataHelperExpertsGB(helperGB)+strataDropHeadroomGB {
|
||||||
|
return "drop"
|
||||||
|
}
|
||||||
|
if availGB >= arena+strataLowRAMHeadroomGB {
|
||||||
|
return "ram"
|
||||||
|
}
|
||||||
|
if binReady {
|
||||||
|
return "arena"
|
||||||
|
}
|
||||||
|
return "disk"
|
||||||
|
}
|
||||||
|
|
||||||
|
// strataExpertsLabel : le mode en clair, pour l'interface et le journal.
|
||||||
|
func strataExpertsLabel(mode string) string {
|
||||||
|
switch mode {
|
||||||
|
case "drop":
|
||||||
|
return "verrouillés en RAM, hors ceux de la carte d'appoint"
|
||||||
|
case "ram":
|
||||||
|
return "chargés et verrouillés en RAM"
|
||||||
|
case "arena":
|
||||||
|
return "experts.bin mappé (cache système)"
|
||||||
|
case "disk":
|
||||||
|
return "lus depuis le disque (mmap)"
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
// strataMaxCtx : contexte natif maximal de Qwen3.8 Flash Next.
|
// strataMaxCtx : contexte natif maximal de Qwen3.8 Flash Next.
|
||||||
const strataMaxCtx = 262144
|
const strataMaxCtx = 262144
|
||||||
@@ -162,6 +295,7 @@ type strataEnv struct {
|
|||||||
Main int `json:"main"` // index nvidia-smi de la carte principale
|
Main int `json:"main"` // index nvidia-smi de la carte principale
|
||||||
Helper int `json:"helper"` // index de la carte d'aide, -1 sans
|
Helper int `json:"helper"` // index de la carte d'aide, -1 sans
|
||||||
RAMGB float64 `json:"ram_gb"`
|
RAMGB float64 `json:"ram_gb"`
|
||||||
|
AvailGB float64 `json:"avail_gb"` // RAM disponible (+ celle du moteur Strata en marche)
|
||||||
DiskFreeGB float64 `json:"disk_free_gb"`
|
DiskFreeGB float64 `json:"disk_free_gb"`
|
||||||
Python string `json:"python,omitempty"`
|
Python string `json:"python,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -171,7 +305,10 @@ const strataMinArch = 75
|
|||||||
|
|
||||||
func strataDetect() strataEnv {
|
func strataDetect() strataEnv {
|
||||||
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
|
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
|
||||||
if f := diskFree(LokiHome()); f > 0 {
|
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 := strataDiskFree(strataHome()); f > 0 {
|
||||||
env.DiskFreeGB = float64(f) / 1e9
|
env.DiskFreeGB = float64(f) / 1e9
|
||||||
}
|
}
|
||||||
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
|
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
|
||||||
@@ -281,32 +418,60 @@ func strataHaveGB(family, quant string) float64 {
|
|||||||
strataDirGB(filepath.Join(strataDataDir(), "mtp"))
|
strataDirGB(filepath.Join(strataDataDir(), "mtp"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// strataNeedsMmap : les experts ne tiennent pas en RAM avec la marge
|
// strataEngineRSSGB : la RAM tenue par le moteur Strata qui tourne (serveur et
|
||||||
// nécessaire, ils seront lus en mmap depuis un fichier à part (experts.bin).
|
// moteur, arbre du service), en Go. Ajoutée à la RAM disponible quand on prédit
|
||||||
func strataNeedsMmap(q strataQuant, ramGB float64) bool {
|
// pour CETTE machine : sinon le moteur en marche ferait croire qu'il ne tient pas.
|
||||||
return ramGB < q.ArenaGB+strataLowRAMHeadroomGB
|
func strataEngineRSSGB() float64 {
|
||||||
}
|
if !strataActive() {
|
||||||
|
return 0
|
||||||
// strataDropFits : avec une carte d'aide, les experts qu'elle garde ne sont pas
|
|
||||||
// en RAM ; le reste est verrouillé en RAM (plus rapide que le mmap : génération
|
|
||||||
// ~64 au lieu de ~56 tok/s, lecture +17 à +38 %). Il faut que ce reste tienne.
|
|
||||||
func strataDropFits(q strataQuant, env strataEnv) bool {
|
|
||||||
if env.Helper < 0 {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
for _, g := range env.GPUs {
|
root := readServicePID()
|
||||||
if g.Index == env.Helper {
|
if root <= 0 {
|
||||||
return env.RAMGB >= q.ArenaGB-strataHelperExpertsGB(g.VRAMGB)+strataDropHeadroomGB
|
return 0
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parent := map[int]int{}
|
||||||
|
rss := map[int]float64{}
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(filepath.Join("/proc", e.Name(), "status"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "PPid:"); ok {
|
||||||
|
parent[pid], _ = strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
} else if v, ok := strings.CutPrefix(line, "VmRSS:"); ok {
|
||||||
|
if f := strings.Fields(v); len(f) > 0 {
|
||||||
|
kb, _ := strconv.ParseFloat(f[0], 64)
|
||||||
|
rss[pid] = kb * 1024 / 1e9
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
var total float64
|
||||||
|
for pid, r := range rss {
|
||||||
|
for p, n := pid, 0; p > 0 && n < 64; p, n = parent[p], n+1 {
|
||||||
|
if p == root {
|
||||||
|
total += r
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
// strataFit : ce qu'un quant demande sur cette machine, et s'il y tient.
|
// strataFit : ce qu'un quant demande sur cette machine, et s'il y tient.
|
||||||
type strataFit struct {
|
type strataFit struct {
|
||||||
Quant strataQuant `json:"quant"`
|
Quant strataQuant `json:"quant"`
|
||||||
DiskGB float64 `json:"disk_gb"`
|
DiskGB float64 `json:"disk_gb"`
|
||||||
Mmap bool `json:"mmap"`
|
Mmap bool `json:"mmap"` // en mode auto, les experts seraient lus depuis le disque
|
||||||
Drop bool `json:"drop"` // experts de la carte d'aide hors RAM, le reste verrouillé
|
Drop bool `json:"drop"` // experts de la carte d'aide hors RAM, le reste verrouillé
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Why string `json:"why,omitempty"`
|
Why string `json:"why,omitempty"`
|
||||||
@@ -329,12 +494,19 @@ type strataDetails struct {
|
|||||||
CacheRoot string `json:"cache_root"`
|
CacheRoot string `json:"cache_root"`
|
||||||
Mmap bool `json:"mmap"`
|
Mmap bool `json:"mmap"`
|
||||||
Drop bool `json:"drop"`
|
Drop bool `json:"drop"`
|
||||||
MainGPU string `json:"main_gpu"`
|
// Experts : le choix du preset (auto, ram, disk) ; Mode : le mode lancé
|
||||||
HelperGPU string `json:"helper_gpu,omitempty"`
|
// (drop, ram, arena, disk) — celui du dernier lancement pour le preset
|
||||||
VisionGPU string `json:"vision_gpu,omitempty"`
|
// actif, sinon celui que la RAM disponible donnerait maintenant.
|
||||||
|
Experts string `json:"experts"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
ModeLabel string `json:"mode_label"`
|
||||||
|
MainGPU string `json:"main_gpu"`
|
||||||
|
HelperGPU string `json:"helper_gpu,omitempty"`
|
||||||
|
VisionGPU string `json:"vision_gpu,omitempty"`
|
||||||
// pour les réglages : ce que la machine permet et l'état actuel
|
// pour les réglages : ce que la machine permet et l'état actuel
|
||||||
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
|
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
|
||||||
HelperOn bool `json:"helper_on"`
|
HelperOn bool `json:"helper_on"`
|
||||||
|
Split bool `json:"split_layers"` // les deux cartes se partagent les couches
|
||||||
Vision bool `json:"vision"`
|
Vision bool `json:"vision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +525,13 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
|
|||||||
if json.Unmarshal(raw, &base) != nil {
|
if json.Unmarshal(raw, &base) != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out, err := strataBuildConfig(base, pc, "")
|
mode := strataResolveExperts(pc, strataArgList(base), env.AvailGB, strataHelperVRAM(pc, env.GPUs))
|
||||||
|
if active, _ := strataPresetActive(presetID); active {
|
||||||
|
if m := getStr(bkState, strataLaunchModeKey); m != "" {
|
||||||
|
mode = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := strataBuildConfig(base, pc, "", mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -379,15 +557,18 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
|
|||||||
Spec: val("--spec"), Prefill: val("--prefill"), ShortRead: val("--short-read"),
|
Spec: val("--spec"), Prefill: val("--prefill"), ShortRead: val("--short-read"),
|
||||||
CacheRoot: val("--prompt-cache-root"), MainGPU: gpuName(pc["STRATA_MAIN_GPU"]),
|
CacheRoot: val("--prompt-cache-root"), MainGPU: gpuName(pc["STRATA_MAIN_GPU"]),
|
||||||
}
|
}
|
||||||
if e, ok := out["env"].(map[string]any); ok {
|
d.Mode, d.ModeLabel = mode, strataExpertsLabel(mode)
|
||||||
d.Mmap = fmt.Sprint(e["STRATA_ARENA_MMAP"]) == "1"
|
d.Mmap, d.Drop = mode == "disk" || mode == "arena", mode == "drop"
|
||||||
d.Drop = fmt.Sprint(e["STRATA_REMOTE_DROP"]) == "1"
|
d.Experts = strings.ToLower(strings.TrimSpace(pc["STRATA_EXPERTS"]))
|
||||||
|
if d.Experts != strataExpertsRAM && d.Experts != strataExpertsDisk {
|
||||||
|
d.Experts = strataExpertsAuto
|
||||||
}
|
}
|
||||||
if h := strings.TrimSpace(pc["STRATA_HELPER_GPU"]); h != "" && h != "-1" {
|
if h := strings.TrimSpace(pc["STRATA_HELPER_GPU"]); h != "" && h != "-1" {
|
||||||
d.HasHelper = true
|
d.HasHelper = true
|
||||||
if strataHelperOn(pc) {
|
if strataHelperOn(pc) {
|
||||||
d.HelperOn = true
|
d.HelperOn = true
|
||||||
d.HelperGPU = gpuName(h)
|
d.HelperGPU = gpuName(h)
|
||||||
|
d.Split = strataLayerSplit(pc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.Vision = pc["STRATA_VISION"] != "0"
|
d.Vision = pc["STRATA_VISION"] != "0"
|
||||||
@@ -402,15 +583,11 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func strataFitFor(q strataQuant, env strataEnv, haveGB float64) strataFit {
|
func strataFitFor(q strataQuant, env strataEnv, haveGB float64) strataFit {
|
||||||
f := strataFit{Quant: q, Mmap: strataNeedsMmap(q, env.RAMGB)}
|
// Prédiction du mode auto sur la RAM DISPONIBLE (pas la RAM totale) : sous
|
||||||
if f.Mmap && strataDropFits(q, env) {
|
// la marge, les experts seront lus depuis le disque. Le mode « hors RAM » de
|
||||||
f.Mmap, f.Drop = false, true
|
// la carte d'appoint demande un experts.bin, qu'une installation neuve n'a pas.
|
||||||
}
|
f := strataFit{Quant: q, Mmap: env.AvailGB < q.ArenaGB+strataLowRAMHeadroomGB}
|
||||||
f.DiskGB = q.DownloadGB
|
f.DiskGB = q.DownloadGB
|
||||||
// les deux modes lisent les experts dans experts.bin, écrit au premier lancement
|
|
||||||
if f.Mmap || f.Drop {
|
|
||||||
f.DiskGB += strataMmapExtraGB
|
|
||||||
}
|
|
||||||
// ce qui est déjà là ne se retélécharge pas ; il reste toujours les
|
// ce qui est déjà là ne se retélécharge pas ; il reste toujours les
|
||||||
// environnements (Python, bibliothèques CUDA, moteur) : ~10 Go
|
// environnements (Python, bibliothèques CUDA, moteur) : ~10 Go
|
||||||
f.DiskGB = max(f.DiskGB-haveGB, 10)
|
f.DiskGB = max(f.DiskGB-haveGB, 10)
|
||||||
@@ -623,7 +800,10 @@ func strataRunInstall(req strataInstallReq) {
|
|||||||
// réserver sa propre variante (qui ne voit pas un experts.bin présent).
|
// réserver sa propre variante (qui ne voit pas un experts.bin présent).
|
||||||
"--low-ram", "off",
|
"--low-ram", "off",
|
||||||
}
|
}
|
||||||
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1"
|
// XDG_CACHE_HOME : les caches de pip et de Hugging Face (~/.cache, soit
|
||||||
|
// /root dans le conteneur, donc l'image Docker — 20 Go sur Unraid) suivent
|
||||||
|
// le reste de Strata dans /data/strata.
|
||||||
|
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1\x00XDG_CACHE_HOME=" + filepath.Join(strataHome(), "cache")
|
||||||
if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil {
|
if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil {
|
||||||
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
|
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
|
||||||
return
|
return
|
||||||
@@ -646,8 +826,9 @@ func strataRunInstall(req strataInstallReq) {
|
|||||||
"STRATA_MAIN_GPU=" + strconv.Itoa(env.Main),
|
"STRATA_MAIN_GPU=" + strconv.Itoa(env.Main),
|
||||||
"STRATA_HELPER_GPU=" + strconv.Itoa(env.Helper),
|
"STRATA_HELPER_GPU=" + strconv.Itoa(env.Helper),
|
||||||
"STRATA_VISION=1",
|
"STRATA_VISION=1",
|
||||||
"STRATA_MMAP=" + strataBool(fit.Mmap),
|
// experts placés au lancement selon la RAM disponible (strataResolveExperts)
|
||||||
"STRATA_DROP=" + strataBool(fit.Drop),
|
"STRATA_EXPERTS=" + strataExpertsAuto,
|
||||||
|
"STRATA_DROP=1",
|
||||||
// la meilleure qualité validée par AJEAN : cache KV en fp16 (32K en
|
// la meilleure qualité validée par AJEAN : cache KV en fp16 (32K en
|
||||||
// VRAM, le reste en RAM pour ne pas prendre la place des experts) et 3
|
// VRAM, le reste en RAM pour ne pas prendre la place des experts) et 3
|
||||||
// jetons de brouillon MTP (mesuré meilleur que 4)
|
// jetons de brouillon MTP (mesuré meilleur que 4)
|
||||||
@@ -703,7 +884,8 @@ func strataFindConfig(quant string) (string, error) {
|
|||||||
//
|
//
|
||||||
// - --prefill 16384 : +15 % de lecture sur les longs prompts (40K) ;
|
// - --prefill 16384 : +15 % de lecture sur les longs prompts (40K) ;
|
||||||
// - --short-read 150 : les petits ajouts (< 150 jetons) sont lus comme en
|
// - --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
|
// - --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.
|
// (défaut 2048) : une nouvelle conversation démarre en ~0,3 s au lieu de 4-5 s.
|
||||||
var strataTuning = map[string]string{
|
var strataTuning = map[string]string{
|
||||||
@@ -773,8 +955,9 @@ func strataArgList(cfg map[string]any) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// strataBuildConfig lit la config de l'installeur et y applique les réglages
|
// strataBuildConfig lit la config de l'installeur et y applique les réglages
|
||||||
// du preset. Pure (testable) : ne lance rien.
|
// du preset, avec les experts placés selon `mode` (strataResolveExperts). Pure
|
||||||
func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string) (map[string]any, error) {
|
// (testable) : ne lance rien.
|
||||||
|
func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode string) (map[string]any, error) {
|
||||||
raw, _ := json.Marshal(base)
|
raw, _ := json.Marshal(base)
|
||||||
var out map[string]any
|
var out map[string]any
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
@@ -789,6 +972,16 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string
|
|||||||
}
|
}
|
||||||
helper := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
|
helper := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
|
||||||
hasHelper := strataHelperOn(cfg)
|
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
|
// 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
|
// chemin « helper » du moteur), la carte principale reste CUDA0. Les cartes
|
||||||
// sont posées par CUDA_VISIBLE_DEVICES (serveStrata), PAS par la clé « gpu » :
|
// sont posées par CUDA_VISIBLE_DEVICES (serveStrata), PAS par la clé « gpu » :
|
||||||
@@ -811,39 +1004,64 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Mode « peu de RAM » : l'installeur écrit --mmap-experts / --resident-experts.
|
// Experts (strataResolveExperts) : les drapeaux de l'installeur
|
||||||
// On applique à la place la variante mesurée par AJEAN : le pack experts.bin
|
// (--mmap-experts / --resident-experts) sont remplacés par ceux du mode.
|
||||||
// mappé tel quel (STRATA_ARENA_MMAP, le système garde en cache ce que la RAM
|
|
||||||
// permet), sans part PCIe (le mappage n'en donne pas l'adresse GPU). Le
|
|
||||||
// moteur écrit experts.bin lui-même au premier démarrage s'il manque.
|
|
||||||
env := map[string]any{}
|
env := map[string]any{}
|
||||||
if e, ok := out["env"].(map[string]any); ok {
|
if e, ok := out["env"].(map[string]any); ok {
|
||||||
env = e
|
env = e
|
||||||
}
|
}
|
||||||
if hasHelper && cfg["STRATA_DROP"] == "1" && strataDropReady(args) {
|
args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts")
|
||||||
// Les experts de la carte d'aide hors RAM, le reste verrouillé en RAM
|
delete(env, "STRATA_ARENA_MMAP")
|
||||||
// (strataDropFits) : la principale relit aussi par PCIe une part de ce
|
delete(env, "STRATA_REMOTE_DROP")
|
||||||
// qui lui manque (part automatique) ; la réserve de la carte d'aide est figée.
|
switch mode {
|
||||||
args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts")
|
case "drop":
|
||||||
|
if !hasHelper {
|
||||||
|
break // sans carte d'appoint, c'est le mode « ram »
|
||||||
|
}
|
||||||
|
// Les experts de la carte d'aide hors RAM, le reste verrouillé en RAM :
|
||||||
|
// la principale relit aussi par PCIe une part de ce qui lui manque (part
|
||||||
|
// automatique) ; la réserve de la carte d'aide est figée.
|
||||||
args = strataDropFlagValue(args, "--pcie-frac")
|
args = strataDropFlagValue(args, "--pcie-frac")
|
||||||
delete(env, "STRATA_ARENA_MMAP")
|
|
||||||
env["STRATA_REMOTE_DROP"] = "1"
|
env["STRATA_REMOTE_DROP"] = "1"
|
||||||
// ses experts sont relus de sa VRAM à la lecture d'un prompt, par les fils
|
// ses experts sont relus de sa VRAM à la lecture d'un prompt, par les fils
|
||||||
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
|
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
|
||||||
env["STRATA_STAGER_THREADS"] = "8"
|
env["STRATA_STAGER_THREADS"] = "8"
|
||||||
env["STRATA_STAGER_RING"] = "32"
|
env["STRATA_STAGER_RING"] = "32"
|
||||||
} else if cfg["STRATA_MMAP"] == "1" || cfg["STRATA_DROP"] == "1" || strataHasArg(args, "--mmap-experts") || strataHasArg(args, "--resident-experts") {
|
// sa VRAM libre réservée : 640 Mio (défaut 1024) = ~230 experts de plus
|
||||||
// (STRATA_DROP sans experts.bin : ce lancement l'écrit, en mmap)
|
// hors RAM, l'encodeur d'images y tourne encore ; le moteur refuse moins
|
||||||
args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts")
|
// 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.
|
||||||
args = strataSetArg(args, "--pcie-frac", "0")
|
args = strataSetArg(args, "--pcie-frac", "0")
|
||||||
env["STRATA_ARENA_MMAP"] = "1"
|
env["STRATA_ARENA_MMAP"] = "1"
|
||||||
|
case "disk":
|
||||||
|
// Lus dans les fichiers du modèle par le cache système : rien n'est
|
||||||
|
// chargé au démarrage. Pas de part PCIe non plus (mémoire non épinglée).
|
||||||
|
args = append(args, "--mmap-experts")
|
||||||
|
args = strataSetArg(args, "--pcie-frac", "0")
|
||||||
}
|
}
|
||||||
for _, kv := range strataEnvTuning {
|
for _, kv := range strataEnvTuning {
|
||||||
k, v, _ := strings.Cut(kv, "=")
|
k, v, _ := strings.Cut(kv, "=")
|
||||||
env[k] = v
|
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
|
out["env"] = env
|
||||||
if ctx := strings.TrimSpace(cfg["CTX"]); ctx != "" {
|
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)
|
args = strataSetArg(args, "--max-context", ctx)
|
||||||
}
|
}
|
||||||
// réglages imposés par le preset (sinon : ceux de l'installeur)
|
// réglages imposés par le preset (sinon : ceux de l'installeur)
|
||||||
@@ -874,23 +1092,50 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// strataDropReady : le mode « experts de la carte d'aide hors RAM » lit les
|
// strataLaunchModeKey : le mode des experts du dernier lancement (bkState),
|
||||||
// experts dans experts.bin (écrit au premier lancement, en mmap) et demande le
|
// montré dans la fenêtre de Strata pour le preset actif.
|
||||||
// moteur du paquet.
|
const strataLaunchModeKey = "strata_experts_mode"
|
||||||
func strataDropReady(args []string) bool {
|
|
||||||
pack := ""
|
// strataHelperVRAM : VRAM (Go) de la carte d'appoint du preset, 0 sans.
|
||||||
for i := 0; i+1 < len(args); i++ {
|
func strataHelperVRAM(cfg map[string]string, gpus []strataGPU) float64 {
|
||||||
if args[i] == "--pack" {
|
if !strataHelperOn(cfg) {
|
||||||
pack = args[i+1]
|
return 0
|
||||||
|
}
|
||||||
|
h := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
|
||||||
|
for _, g := range gpus {
|
||||||
|
if strconv.Itoa(g.Index) == h {
|
||||||
|
return g.VRAMGB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pack == "" {
|
return 0
|
||||||
return false
|
}
|
||||||
|
|
||||||
|
// strataPresetActive : le preset id est-il celui en service ?
|
||||||
|
func strataPresetActive(id string) (bool, error) {
|
||||||
|
list, err := ListPresets()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(pack, "experts.bin")); err != nil {
|
for _, p := range list {
|
||||||
return false
|
if p.ID == id {
|
||||||
|
return p.Active, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return strataEngineInstalled() == strataEngineAsset
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// strataGPUs : les cartes NVIDIA vues par nvidia-smi (nil si indisponible).
|
||||||
|
func strataGPUs() []strataGPU {
|
||||||
|
gpus, err := detectGPUs()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []strataGPU
|
||||||
|
for _, g := range gpus {
|
||||||
|
mib, _ := strconv.ParseFloat(g.MemTotal, 64)
|
||||||
|
out = append(out, strataGPU{Index: g.Index, Name: g.Name, VRAMGB: mib / 1024})
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// strataEngineMarker : le paquet dont vient le moteur installé.
|
// strataEngineMarker : le paquet dont vient le moteur installé.
|
||||||
@@ -960,6 +1205,16 @@ func strataHelperOn(cfg map[string]string) bool {
|
|||||||
return h != "" && h != "-1" && cfg["STRATA_HELPER"] != "0"
|
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.
|
// strataCudaDevices : ordre des cartes vu par le moteur, la principale en CUDA0.
|
||||||
func strataCudaDevices(cfg map[string]string) string {
|
func strataCudaDevices(cfg map[string]string) string {
|
||||||
main := strings.TrimSpace(cfg["STRATA_MAIN_GPU"])
|
main := strings.TrimSpace(cfg["STRATA_MAIN_GPU"])
|
||||||
@@ -982,17 +1237,99 @@ func strataPreflight(cfg map[string]string) error {
|
|||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
return fmt.Errorf("configuration de Strata introuvable : %s — réinstalle le modèle", path)
|
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 {
|
if _, err := os.Stat(py); err != nil {
|
||||||
return fmt.Errorf("environnement Python de Strata absent (%s) : réinstalle le modèle", py)
|
return fmt.Errorf("environnement Python de Strata absent (%s) : réinstalle le modèle", py)
|
||||||
}
|
}
|
||||||
return nil
|
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 à
|
// 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
|
// 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).
|
// Strata (comme pour llama-server : le superviseur suit directement le moteur).
|
||||||
func serveStrata(cfg map[string]string) error {
|
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 {
|
if err := strataPreflight(cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1009,12 +1346,19 @@ func serveStrata(cfg map[string]string) error {
|
|||||||
fmt.Fprintf(os.Stderr, "[loki serve] moteur %s non installé (%v) : l'ancien reste en place\n", strataEngineAsset, err)
|
fmt.Fprintf(os.Stderr, "[loki serve] moteur %s non installé (%v) : l'ancien reste en place\n", strataEngineAsset, err)
|
||||||
}
|
}
|
||||||
apiKey, _ := effectiveAPIKeyErr()
|
apiKey, _ := effectiveAPIKeyErr()
|
||||||
final, err := strataBuildConfig(base, cfg, apiKey)
|
// Experts : décidé ICI, sur la RAM disponible au moment du lancement (l'ancien
|
||||||
|
// moteur est déjà arrêté), pas sur la RAM totale.
|
||||||
|
avail := memAvailableGB()
|
||||||
|
mode := strataResolveExperts(cfg, strataArgList(base), avail, strataHelperVRAM(cfg, strataGPUs()))
|
||||||
|
fmt.Fprintf(os.Stderr, "%s experts : %s (choix %s, %.0f Go de RAM disponibles, %.0f Go d'experts)\n",
|
||||||
|
strataServeMarker, strataExpertsLabel(mode), firstNonEmpty(cfg["STRATA_EXPERTS"], strataExpertsAuto), avail, strataArenaGB(cfg))
|
||||||
|
_ = putStr(bkState, strataLaunchModeKey, mode)
|
||||||
|
final, err := strataBuildConfig(base, cfg, apiKey, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
srcDir := filepath.Dir(path)
|
srcDir := filepath.Dir(path)
|
||||||
py := filepath.Join(srcDir, ".venv", "bin", "python")
|
py := strataVenvPython(srcDir)
|
||||||
tag := strataConfigTag(path)
|
tag := strataConfigTag(path)
|
||||||
final["log"] = filepath.Join(strataHome(), "strata-"+tag+".log")
|
final["log"] = filepath.Join(strataHome(), "strata-"+tag+".log")
|
||||||
out := filepath.Join(strataHome(), "loki-strata-"+tag+".json")
|
out := filepath.Join(strataHome(), "loki-strata-"+tag+".json")
|
||||||
@@ -1110,12 +1454,16 @@ func handleStrata(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// strataSettingsReq : les réglages modifiables d'un modèle installé.
|
// strataSettingsReq : les réglages modifiables d'un modèle installé.
|
||||||
type strataSettingsReq struct {
|
type strataSettingsReq struct {
|
||||||
Preset string `json:"preset"`
|
Preset string `json:"preset"`
|
||||||
Ctx int `json:"ctx"`
|
Ctx int `json:"ctx"`
|
||||||
KV string `json:"kv"`
|
KV string `json:"kv"`
|
||||||
Spec int `json:"spec"`
|
Spec int `json:"spec"`
|
||||||
Vision bool `json:"vision"`
|
Vision bool `json:"vision"`
|
||||||
Helper bool `json:"helper"`
|
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).
|
// strataApplySettings réécrit les clés du preset (pure, testable).
|
||||||
@@ -1128,11 +1476,28 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
|||||||
return "", fmt.Errorf("cache KV inconnu : %s", r.KV)
|
return "", fmt.Errorf("cache KV inconnu : %s", r.KV)
|
||||||
case r.Spec < 2 || r.Spec > 4:
|
case r.Spec < 2 || r.Spec > 4:
|
||||||
return "", fmt.Errorf("MTP hors limites : %d", r.Spec)
|
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{
|
set := map[string]string{
|
||||||
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
|
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
|
||||||
"STRATA_VISION": strataBool(r.Vision), "STRATA_HELPER": strataBool(r.Helper),
|
"STRATA_VISION": strataBool(r.Vision), "STRATA_HELPER": strataBool(r.Helper),
|
||||||
}
|
}
|
||||||
|
keys := []string{"CTX", "STRATA_KV", "STRATA_SPEC", "STRATA_VISION", "STRATA_HELPER"}
|
||||||
|
if r.Experts != "" {
|
||||||
|
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
|
var lines []string
|
||||||
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
|
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
|
||||||
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
|
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
|
||||||
@@ -1141,7 +1506,10 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
|||||||
}
|
}
|
||||||
lines = append(lines, l)
|
lines = append(lines, l)
|
||||||
}
|
}
|
||||||
for _, k := range []string{"CTX", "STRATA_KV", "STRATA_SPEC", "STRATA_VISION", "STRATA_HELPER"} {
|
for _, k := range keys {
|
||||||
|
if set[k] == "" && k == "STRATA_SPLIT" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
lines = append(lines, k+"="+set[k])
|
lines = append(lines, k+"="+set[k])
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n") + "\n", nil
|
return strings.Join(lines, "\n") + "\n", nil
|
||||||
@@ -1173,25 +1541,33 @@ func handleStrataSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
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")
|
path := filepath.Join(presetsDir(), req.Preset+".env")
|
||||||
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
|
||||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
restarted := false
|
restarted := false
|
||||||
if list, err := ListPresets(); err == nil {
|
if active {
|
||||||
for _, p := range list {
|
if tuneDenyHTTP(w) {
|
||||||
if p.ID == req.Preset && p.Active {
|
return
|
||||||
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 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})
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ func TestStrataBuildConfigApplyTuning(t *testing.T) {
|
|||||||
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
|
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
|
||||||
"port": 8080.0,
|
"port": 8080.0,
|
||||||
}
|
}
|
||||||
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k")
|
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k", "ram")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ func TestStrataBuildConfigApplyTuning(t *testing.T) {
|
|||||||
func TestStrataBuildConfigHelperGPU(t *testing.T) {
|
func TestStrataBuildConfigHelperGPU(t *testing.T) {
|
||||||
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"}, "gpu": 1.0,
|
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"}, "gpu": 1.0,
|
||||||
"vision": map[string]any{"exe": "v", "gpu": true}}
|
"vision": map[string]any{"exe": "v", "gpu": true}}
|
||||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "")
|
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -84,70 +84,6 @@ func TestStrataBuildConfigHelperGPU(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStrataDropFits(t *testing.T) {
|
|
||||||
q := strataQuants["IQ3_XXS"]
|
|
||||||
env := strataEnv{Main: 1, Helper: 0, RAMGB: 46.9, GPUs: []strataGPU{{Index: 0, VRAMGB: 7.8}, {Index: 1, VRAMGB: 15.9}}}
|
|
||||||
if !strataDropFits(q, env) {
|
|
||||||
t.Error("serveur de test (46 Go + 3070) : le mode hors RAM devrait tenir")
|
|
||||||
}
|
|
||||||
f := strataFitFor(q, env, 0)
|
|
||||||
if f.Mmap || !f.Drop {
|
|
||||||
t.Errorf("fit = mmap %v drop %v, attendu drop", f.Mmap, f.Drop)
|
|
||||||
}
|
|
||||||
env.Helper = -1
|
|
||||||
if strataDropFits(q, env) {
|
|
||||||
t.Error("sans carte d'aide, pas de mode hors RAM")
|
|
||||||
}
|
|
||||||
env.Helper, env.RAMGB = 0, 32
|
|
||||||
if strataDropFits(q, env) {
|
|
||||||
t.Error("32 Go : ne tient pas, doit rester en mmap")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStrataBuildConfigDrop(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("LOKI_HOME", home)
|
|
||||||
pack := filepath.Join(home, "pack")
|
|
||||||
_ = os.MkdirAll(pack, 0o755)
|
|
||||||
base := map[string]any{"args": []any{"--pack", pack, "--pcie-frac", "0.3"}, "env": map[string]any{}}
|
|
||||||
cfg := map[string]string{"STRATA_HELPER_GPU": "0", "STRATA_DROP": "1", "STRATA_MMAP": "0"}
|
|
||||||
// sans experts.bin ni moteur à jour : repli mmap (ce lancement écrit experts.bin)
|
|
||||||
out, _ := strataBuildConfig(base, cfg, "")
|
|
||||||
if out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != "1" || out["env"].(map[string]any)["STRATA_REMOTE_DROP"] != nil {
|
|
||||||
t.Fatalf("repli mmap attendu : %v", out["env"])
|
|
||||||
}
|
|
||||||
_ = 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)
|
|
||||||
out, _ = strataBuildConfig(base, cfg, "")
|
|
||||||
e := out["env"].(map[string]any)
|
|
||||||
if e["STRATA_REMOTE_DROP"] != "1" || e["STRATA_ARENA_MMAP"] != nil {
|
|
||||||
t.Errorf("mode hors RAM attendu : %v", e)
|
|
||||||
}
|
|
||||||
if a := strataArgs(t, out); strataHasArg(a, "--pcie-frac") {
|
|
||||||
t.Errorf("--pcie-frac gardé (la part PCIe doit être automatique) : %v", a)
|
|
||||||
}
|
|
||||||
// carte d'aide coupée : pas de mode hors RAM
|
|
||||||
cfg["STRATA_HELPER"] = "0"
|
|
||||||
out, _ = strataBuildConfig(base, cfg, "")
|
|
||||||
if out["env"].(map[string]any)["STRATA_REMOTE_DROP"] != nil {
|
|
||||||
t.Error("mode hors RAM sans carte d'aide")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStrataBuildConfigMmap(t *testing.T) {
|
|
||||||
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts"}, "env": map[string]any{"X": "1"}}
|
|
||||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
a := strataArgs(t, out)
|
|
||||||
env := out["env"].(map[string]any)
|
|
||||||
if strataHasArg(a, "--mmap-experts") || argAfter(a, "--pcie-frac") != "0" || env["STRATA_ARENA_MMAP"] != "1" || env["X"] != "1" {
|
|
||||||
t.Errorf("mmap validé non appliqué : %v %v", a, env)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStrataCudaDevices(t *testing.T) {
|
func TestStrataCudaDevices(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
main, helper, want string
|
main, helper, want string
|
||||||
@@ -167,7 +103,7 @@ func TestStrataCudaDevices(t *testing.T) {
|
|||||||
|
|
||||||
func TestStrataRecommend(t *testing.T) {
|
func TestStrataRecommend(t *testing.T) {
|
||||||
// la machine de test : 5060 Ti 16 Go + 3070 8 Go, 47 Go de RAM, disque large
|
// la machine de test : 5060 Ti 16 Go + 3070 8 Go, 47 Go de RAM, disque large
|
||||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 400,
|
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, AvailGB: 47, DiskFreeGB: 400,
|
||||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||||
if got := strataRecommend("swift", env); got != "IQ3_XXS" {
|
if got := strataRecommend("swift", env); got != "IQ3_XXS" {
|
||||||
t.Errorf("swift : %q, attendu IQ3_XXS", got)
|
t.Errorf("swift : %q, attendu IQ3_XXS", got)
|
||||||
@@ -177,12 +113,13 @@ func TestStrataRecommend(t *testing.T) {
|
|||||||
t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got)
|
t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got)
|
||||||
}
|
}
|
||||||
f := strataFitFor(strataQuants["IQ3_XXS"], env, 0)
|
f := strataFitFor(strataQuants["IQ3_XXS"], env, 0)
|
||||||
// trop juste pour tout garder en RAM, mais la 3070 garde les siens : mode hors RAM
|
// trop juste pour tout garder en RAM : experts lus depuis le disque (le mode
|
||||||
if !f.OK || f.Mmap || !f.Drop {
|
// hors RAM demande un experts.bin, qu'une installation neuve n'a pas)
|
||||||
t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, hors RAM)", f.OK, f.Mmap, f.Drop)
|
if !f.OK || !f.Mmap || f.Drop {
|
||||||
|
t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, disque)", f.OK, f.Mmap, f.Drop)
|
||||||
}
|
}
|
||||||
// 64 Go : IQ3_S tient en RAM
|
// 64 Go : IQ3_S tient en RAM
|
||||||
env.RAMGB = 64
|
env.RAMGB, env.AvailGB = 64, 64
|
||||||
if got := strataRecommend("qwen", env); got != "IQ3_S" {
|
if got := strataRecommend("qwen", env); got != "IQ3_S" {
|
||||||
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
|
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
|
||||||
}
|
}
|
||||||
@@ -208,22 +145,6 @@ func TestStrataPresetName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStrataBuildConfigMmapFromPreset(t *testing.T) {
|
|
||||||
base := map[string]any{"args": []any{"--pack", "/p"}}
|
|
||||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_MMAP": "1"}, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
a := strataArgs(t, out)
|
|
||||||
if argAfter(a, "--pcie-frac") != "0" || out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != "1" {
|
|
||||||
t.Errorf("STRATA_MMAP=1 non appliqué : %v %v", a, out["env"])
|
|
||||||
}
|
|
||||||
out, _ = strataBuildConfig(base, map[string]string{"STRATA_MMAP": "0"}, "")
|
|
||||||
if out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != nil {
|
|
||||||
t.Errorf("mmap appliqué sans STRATA_MMAP : %v", out["env"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStrataFitCountsExistingData(t *testing.T) {
|
func TestStrataFitCountsExistingData(t *testing.T) {
|
||||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 32,
|
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 32,
|
||||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||||
@@ -237,7 +158,7 @@ func TestStrataFitCountsExistingData(t *testing.T) {
|
|||||||
|
|
||||||
func TestStrataBuildConfigPresetOverrides(t *testing.T) {
|
func TestStrataBuildConfigPresetOverrides(t *testing.T) {
|
||||||
base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}}
|
base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}}
|
||||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "")
|
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "", "ram")
|
||||||
a := strataArgs(t, out)
|
a := strataArgs(t, out)
|
||||||
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
|
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
|
||||||
t.Errorf("%v", a)
|
t.Errorf("%v", a)
|
||||||
@@ -263,7 +184,7 @@ func TestStrataDetailsFor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
|
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.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)
|
t.Errorf("%+v", *d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,7 +228,7 @@ func TestStrataApplySettings(t *testing.T) {
|
|||||||
t.Error("carte d'aide coupée mais encore utilisée")
|
t.Error("carte d'aide coupée mais encore utilisée")
|
||||||
}
|
}
|
||||||
base := map[string]any{"args": []any{"--vision", "--pack", "/p"}, "vision": map[string]any{"exe": "v"}}
|
base := map[string]any{"args": []any{"--vision", "--pack", "/p"}, "vision": map[string]any{"exe": "v"}}
|
||||||
b, _ := strataBuildConfig(base, cfg, "")
|
b, _ := strataBuildConfig(base, cfg, "", "ram")
|
||||||
if strataHasArg(strataArgs(t, b), "--vision") || b["vision"] != nil || strataHasArg(strataArgs(t, b), "--expert-cache-device1") {
|
if strataHasArg(strataArgs(t, b), "--vision") || b["vision"] != nil || strataHasArg(strataArgs(t, b), "--expert-cache-device1") {
|
||||||
t.Errorf("vision / aide coupées mais présentes : %v", b)
|
t.Errorf("vision / aide coupées mais présentes : %v", b)
|
||||||
}
|
}
|
||||||
@@ -341,3 +262,118 @@ func TestModelLoadErrorStrata(t *testing.T) {
|
|||||||
t.Errorf("ancienne trace prise pour l'échec en cours : %q", got)
|
t.Errorf("ancienne trace prise pour l'échec en cours : %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Où vont les experts : décidé sur la RAM DISPONIBLE. Vécu : 62 Go dont 18 pris
|
||||||
|
// ailleurs, mode « hors RAM » choisi sur la RAM totale, moteur tué par le noyau.
|
||||||
|
func TestStrataResolveExperts(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("LOKI_HOME", home)
|
||||||
|
pack := filepath.Join(home, "pack")
|
||||||
|
_ = os.MkdirAll(pack, 0o755)
|
||||||
|
args := []string{"--pack", pack}
|
||||||
|
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
|
||||||
|
// IQ3_XXS : 42,9 Go d'experts
|
||||||
|
cases := []struct {
|
||||||
|
experts string
|
||||||
|
avail float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", 44, "disk"}, // le cas vécu : 44 Go disponibles, pas de experts.bin
|
||||||
|
{"", 60, "ram"}, // assez de RAM pour tout charger avec la marge
|
||||||
|
{"auto", 52.8, "disk"},
|
||||||
|
{"disk", 60, "disk"}, // choix de l'utilisateur
|
||||||
|
{"ram", 20, "ram"}, // choix de l'utilisateur, même à l'étroit
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
cfg["STRATA_EXPERTS"] = c.experts
|
||||||
|
if got := strataResolveExperts(cfg, args, c.avail, 12); got != c.want {
|
||||||
|
t.Errorf("experts=%q dispo=%.0f : %s, attendu %s", c.experts, c.avail, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// experts.bin présent (moteur du paquet) : le mode hors RAM et le mappage deviennent possibles
|
||||||
|
_ = 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)
|
||||||
|
cfg["STRATA_EXPERTS"] = ""
|
||||||
|
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
|
||||||
|
t.Errorf("44 Go + carte d'appoint 12 Go + experts.bin : %s, attendu drop", got)
|
||||||
|
}
|
||||||
|
if got := strataResolveExperts(cfg, args, 30, 12); got != "arena" {
|
||||||
|
t.Errorf("30 Go + experts.bin : %s, attendu arena", got)
|
||||||
|
}
|
||||||
|
cfg["STRATA_HELPER"] = "0"
|
||||||
|
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
|
||||||
|
t.Errorf("carte d'appoint coupée : %s, attendu arena", got)
|
||||||
|
}
|
||||||
|
cfg["STRATA_EXPERTS"] = "disk"
|
||||||
|
if got := strataResolveExperts(cfg, args, 60, 12); got != "arena" {
|
||||||
|
t.Errorf("disque demandé avec experts.bin : %s, attendu arena", got)
|
||||||
|
}
|
||||||
|
// quant inconnu : on ne parie pas sur la RAM
|
||||||
|
if got := strataResolveExperts(map[string]string{"STRATA_CONFIG": "/x/autre.json"}, nil, 500, 0); got != "disk" {
|
||||||
|
t.Errorf("quant inconnu : %s, attendu disk", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrataBuildConfigModes(t *testing.T) {
|
||||||
|
base := map[string]any{"args": []any{"--pack", "/p", "--resident-experts", "--pcie-frac", "0.3"}, "env": map[string]any{"X": "1"}}
|
||||||
|
cfg := map[string]string{"STRATA_HELPER_GPU": "1"}
|
||||||
|
env := func(m map[string]any) map[string]any { return m["env"].(map[string]any) }
|
||||||
|
|
||||||
|
out, _ := strataBuildConfig(base, cfg, "", "disk")
|
||||||
|
a := strataArgs(t, out)
|
||||||
|
if !strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || argAfter(a, "--pcie-frac") != "0" ||
|
||||||
|
env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["X"] != "1" {
|
||||||
|
t.Errorf("disk : %v %v", a, env(out))
|
||||||
|
}
|
||||||
|
out, _ = strataBuildConfig(base, cfg, "", "ram")
|
||||||
|
a = strataArgs(t, out)
|
||||||
|
if strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["STRATA_REMOTE_DROP"] != nil {
|
||||||
|
t.Errorf("ram : %v %v", a, env(out))
|
||||||
|
}
|
||||||
|
out, _ = strataBuildConfig(base, cfg, "", "arena")
|
||||||
|
if a = strataArgs(t, out); argAfter(a, "--pcie-frac") != "0" || env(out)["STRATA_ARENA_MMAP"] != "1" {
|
||||||
|
t.Errorf("arena : %v %v", a, env(out))
|
||||||
|
}
|
||||||
|
out, _ = strataBuildConfig(base, cfg, "", "drop")
|
||||||
|
if a = strataArgs(t, out); strataHasArg(a, "--pcie-frac") || env(out)["STRATA_REMOTE_DROP"] != "1" {
|
||||||
|
t.Errorf("drop : la part PCIe doit être automatique : %v %v", a, env(out))
|
||||||
|
}
|
||||||
|
// drop sans carte d'appoint : rien de spécial, c'est le mode ram
|
||||||
|
out, _ = strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "-1"}, "", "drop")
|
||||||
|
if env(out)["STRATA_REMOTE_DROP"] != nil {
|
||||||
|
t.Error("mode hors RAM sans carte d'appoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrataApplySettingsExperts(t *testing.T) {
|
||||||
|
in := "ENGINE=strata\nSTRATA_EXPERTS=auto\n"
|
||||||
|
r := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Experts: "disk"}
|
||||||
|
out, err := strataApplySettings(in, r)
|
||||||
|
if err != nil || parseEnv(out)["STRATA_EXPERTS"] != "disk" {
|
||||||
|
t.Fatalf("%v %q", err, out)
|
||||||
|
}
|
||||||
|
r.Experts = "nvme"
|
||||||
|
if _, err := strataApplySettings(in, r); err == nil {
|
||||||
|
t.Error("placement inconnu accepté")
|
||||||
|
}
|
||||||
|
r.Experts = ""
|
||||||
|
if out, _ := strataApplySettings(in, r); parseEnv(out)["STRATA_EXPERTS"] != "auto" {
|
||||||
|
t.Errorf("vide doit laisser le choix en place : %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
|
||||||
|
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
|
||||||
|
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
|
||||||
|
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
|
||||||
|
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
|
||||||
|
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
|
||||||
|
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
|
||||||
|
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
|
||||||
|
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
|
||||||
|
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
|
||||||
|
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
|
||||||
|
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,15 @@ func tuneActive() (tuneOwner, bool) {
|
|||||||
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
||||||
var tuneHeld sync.Map
|
var tuneHeld sync.Map
|
||||||
|
|
||||||
|
// tuneFileMu sérialise, DANS CE PROCESSUS, les opérations sur le fichier de
|
||||||
|
// verrou : prise, réécriture, rendu, et le déplacement-relecture du ménage
|
||||||
|
// (tuneReapStale). Sans lui, le ménage pouvait déplacer un verrou vivant
|
||||||
|
// pendant que release() le cherchait : release, ne le trouvant plus, rendait
|
||||||
|
// l'inscription ; le ménage le remettait ensuite en place, désormais orphelin,
|
||||||
|
// et le ménage suivant le prenait pour une optimisation interrompue (moteur
|
||||||
|
// relancé, preset « rétabli »). Vu en CI : TestTuneRecoverNeverReapsOwnLock.
|
||||||
|
var tuneFileMu sync.Mutex
|
||||||
|
|
||||||
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
|
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
|
||||||
|
|
||||||
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
|
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
|
||||||
@@ -201,6 +210,7 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
|||||||
key := tuneHeldKey(l.owner)
|
key := tuneHeldKey(l.owner)
|
||||||
tuneHeld.Store(key, true)
|
tuneHeld.Store(key, true)
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
tuneFileMu.Lock()
|
||||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
b, _ := json.Marshal(l.owner)
|
b, _ := json.Marshal(l.owner)
|
||||||
@@ -209,11 +219,14 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
|||||||
if werr != nil || cerr != nil {
|
if werr != nil || cerr != nil {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
tuneHeld.Delete(key)
|
tuneHeld.Delete(key)
|
||||||
|
tuneFileMu.Unlock()
|
||||||
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
||||||
}
|
}
|
||||||
l.held = true
|
l.held = true
|
||||||
|
tuneFileMu.Unlock()
|
||||||
return l, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
tuneFileMu.Unlock()
|
||||||
if !os.IsExist(err) {
|
if !os.IsExist(err) {
|
||||||
tuneHeld.Delete(key)
|
tuneHeld.Delete(key)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -232,6 +245,13 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
|||||||
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
||||||
// lecteur ne voit jamais un JSON à moitié écrit.
|
// lecteur ne voit jamais un JSON à moitié écrit.
|
||||||
func (l *tuneLock) write() error {
|
func (l *tuneLock) write() error {
|
||||||
|
tuneFileMu.Lock()
|
||||||
|
defer tuneFileMu.Unlock()
|
||||||
|
return l.writeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLocked : write, tuneFileMu déjà tenu par l'appelant.
|
||||||
|
func (l *tuneLock) writeLocked() error {
|
||||||
b, err := json.Marshal(l.owner)
|
b, err := json.Marshal(l.owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -291,6 +311,8 @@ func (l *tuneLock) setBackup(backup, id, name string) {
|
|||||||
func (l *tuneLock) release() {
|
func (l *tuneLock) release() {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
tuneFileMu.Lock()
|
||||||
|
defer tuneFileMu.Unlock()
|
||||||
if l.held {
|
if l.held {
|
||||||
// Un fichier absent un instant (renommé par un ménage qui le remet en
|
// Un fichier absent un instant (renommé par un ménage qui le remet en
|
||||||
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
|
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
|
||||||
@@ -305,7 +327,7 @@ func (l *tuneLock) release() {
|
|||||||
if ours {
|
if ours {
|
||||||
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
||||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
||||||
_ = l.write()
|
_ = l.writeLocked()
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
||||||
break
|
break
|
||||||
@@ -328,6 +350,24 @@ func (l *tuneLock) release() {
|
|||||||
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
|
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
|
||||||
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
|
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
|
||||||
func tuneReapStale() (mainWasActive bool) {
|
func tuneReapStale() (mainWasActive bool) {
|
||||||
|
o, ok := tuneReapClaim()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
||||||
|
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
||||||
|
tuneKillTree(o.Trial.PID, nil)
|
||||||
|
}
|
||||||
|
tuneUndoApply(o)
|
||||||
|
return o.MainWasActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuneReapClaim : la partie de tuneReapStale qui touche au fichier, sous
|
||||||
|
// tuneFileMu — lecture, déplacement, relecture, remise en place. Rend le
|
||||||
|
// verrou périmé effectivement retiré (ok=false : rien à écarter).
|
||||||
|
func tuneReapClaim() (tuneOwner, bool) {
|
||||||
|
tuneFileMu.Lock()
|
||||||
|
defer tuneFileMu.Unlock()
|
||||||
path := tuneLockPath()
|
path := tuneLockPath()
|
||||||
o, ok := readTuneOwner(path)
|
o, ok := readTuneOwner(path)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -338,14 +378,14 @@ func tuneReapStale() (mainWasActive bool) {
|
|||||||
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
}
|
}
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
if tuneOwnerAlive(o) {
|
if tuneOwnerAlive(o) {
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
||||||
if os.Rename(path, stale) != nil {
|
if os.Rename(path, stale) != nil {
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
defer os.Remove(stale)
|
defer os.Remove(stale)
|
||||||
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
|
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
|
||||||
@@ -358,14 +398,9 @@ func tuneReapStale() (mainWasActive bool) {
|
|||||||
if err := os.Link(stale, path); err != nil {
|
if err := os.Link(stale, path); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
|
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
|
||||||
}
|
}
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
return o, true
|
||||||
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
|
||||||
tuneKillTree(o.Trial.PID, nil)
|
|
||||||
}
|
|
||||||
tuneUndoApply(o)
|
|
||||||
return o.MainWasActive
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
||||||
|
|||||||
@@ -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),
|
res := &tuneResult{PresetID: preset.ID, PresetName: preset.Name, BaseFP: configFingerprint(cfg),
|
||||||
PresetFP: presetFingerprint([]byte(content)), OptIn: opts.OptIn, At: time.Now().Unix(),
|
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.
|
// 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")
|
tr.setPhase("référence : composition de la ligne de commande")
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func cmdChat(args []string) error {
|
|||||||
case "edit":
|
case "edit":
|
||||||
icon = "✏️"
|
icon = "✏️"
|
||||||
verb = "édition"
|
verb = "édition"
|
||||||
case "web_search", "web_open", "web_read", "web_grep":
|
case "web_search", "web_images", "web_open", "web_read", "web_grep":
|
||||||
icon = "🌐"
|
icon = "🌐"
|
||||||
verb = "web"
|
verb = "web"
|
||||||
case "web_screenshot":
|
case "web_screenshot":
|
||||||
|
|||||||
@@ -242,6 +242,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
|
|||||||
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
|
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
|
// 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
|
// 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.
|
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
|
||||||
@@ -437,18 +473,24 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
|||||||
var index []recallEntry
|
var index []recallEntry
|
||||||
if caps.Agent && compactEnabled() {
|
if caps.Agent && compactEnabled() {
|
||||||
names := torsoToolNames(torso)
|
names := torsoToolNames(torso)
|
||||||
|
var blocks []recallArchiveInput
|
||||||
|
var at []int // position dans le torse de chaque bloc
|
||||||
for i, m := range torso {
|
for i, m := range torso {
|
||||||
if !recallEligible(m) {
|
if !recallEligible(m) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
content := msgText(m)
|
content := msgText(m)
|
||||||
label := recallLabel(m.Role, names[m.ToolCallID], content)
|
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
|
||||||
id, aerr := archiveRecallBlock(label, m.Role, content)
|
at = append(at, i)
|
||||||
if aerr != nil {
|
}
|
||||||
continue
|
// 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}
|
} else if len(blocks) > 0 {
|
||||||
index = append(index, recallEntry{id: id, label: label})
|
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +570,14 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
|||||||
} else {
|
} else {
|
||||||
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
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
|
var mid []Message
|
||||||
if err != nil || summaryLooksEmpty(summary) {
|
if err != nil || summaryLooksEmpty(summary) {
|
||||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
// 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
|
// errBenchBusy pour un bench, errTuneBusy pour l'optimiseur, qui prend le
|
||||||
// même verrou (backend_tune_job.go).
|
// même verrou (backend_tune_job.go).
|
||||||
leaseWhy error
|
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
|
// 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
|
// 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.
|
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
|
||||||
conv.Generating = false
|
conv.Generating = false
|
||||||
conv.cancel = nil
|
conv.cancel = nil
|
||||||
|
conv.extLeaseClearLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil
|
// 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 {
|
if !c.benching {
|
||||||
c.Generating = false
|
c.Generating = false
|
||||||
c.cancel = nil
|
c.cancel = nil
|
||||||
|
c.extLeaseClearLocked() // le tour de la façade est rompu, comme un tour Go
|
||||||
}
|
}
|
||||||
c.epoch++
|
c.epoch++
|
||||||
c.cond.Broadcast()
|
c.cond.Broadcast()
|
||||||
@@ -354,6 +358,17 @@ func compactLog(log []LogEvent) []LogEvent {
|
|||||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||||
flush()
|
flush()
|
||||||
if done, _ := tu["done"].(bool); done {
|
if done, _ := tu["done"].(bool); done {
|
||||||
|
// L'aperçu du navigateur (shot) n'est que du direct : on ne le
|
||||||
|
// garde pas une fois le tour fini (voir cu_autoshot.go).
|
||||||
|
if _, has := tu["shot"]; has {
|
||||||
|
cp := make(map[string]any, len(tu))
|
||||||
|
for k, v := range tu {
|
||||||
|
if k != "shot" {
|
||||||
|
cp[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ev.Delta = map[string]any{"tool_used": cp}
|
||||||
|
}
|
||||||
out = append(out, ev)
|
out = append(out, ev)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -945,6 +960,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
|||||||
if ev.ToolUsed.Image != "" {
|
if ev.ToolUsed.Image != "" {
|
||||||
tu["image"] = ev.ToolUsed.Image
|
tu["image"] = ev.ToolUsed.Image
|
||||||
}
|
}
|
||||||
|
if ev.ToolUsed.Shot != "" {
|
||||||
|
tu["shot"] = ev.ToolUsed.Shot
|
||||||
|
}
|
||||||
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
||||||
if ev.ToolUsed.ResultChars > 0 {
|
if ev.ToolUsed.ResultChars > 0 {
|
||||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||||
@@ -1185,7 +1203,8 @@ func (c *Conversation) Reset() {
|
|||||||
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
|
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
|
||||||
c.leaseWhy = nil
|
c.leaseWhy = nil
|
||||||
c.cancel = 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.cond.Broadcast()
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.persist()
|
c.persist()
|
||||||
|
|||||||
@@ -378,11 +378,12 @@ func exportFilename(ext string) string {
|
|||||||
// exportBody rend la conversation selon les options. Renvoie le corps, son type
|
// exportBody rend la conversation selon les options. Renvoie le corps, son type
|
||||||
// MIME et l'extension de fichier.
|
// MIME et l'extension de fichier.
|
||||||
func exportBody(o exportOpts) ([]byte, string, string, error) {
|
func exportBody(o exportOpts) ([]byte, string, string, error) {
|
||||||
|
src := exportSource() // la façade peut tenir la conversation (facade_chat.go)
|
||||||
if o.Format == "json" {
|
if o.Format == "json" {
|
||||||
b, err := conv.ExportJSON(o)
|
b, err := src.ExportJSON(o)
|
||||||
return b, "application/json; charset=utf-8", "json", err
|
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).
|
// cmdExport écrit la conversation dans un fichier (ou sur la sortie standard).
|
||||||
|
|||||||
@@ -347,6 +347,9 @@ var (
|
|||||||
|
|
||||||
const pageCacheTTL = 10 * time.Minute
|
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 {
|
type fetchOptions struct {
|
||||||
force bool
|
force bool
|
||||||
actions []string
|
actions []string
|
||||||
@@ -371,7 +374,7 @@ func findCached(rawURL string) *cacheEntry {
|
|||||||
var best *cacheEntry
|
var best *cacheEntry
|
||||||
for k, v := range pageCache {
|
for k, v := range pageCache {
|
||||||
if k == u || strings.HasPrefix(k, u+"#") {
|
if k == u || strings.HasPrefix(k, u+"#") {
|
||||||
if time.Since(v.fetchedAt) > pageCacheTTL {
|
if webNow().Sub(v.fetchedAt) > pageCacheTTL {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if best == nil || v.fetchedAt.After(best.fetchedAt) {
|
if best == nil || v.fetchedAt.After(best.fetchedAt) {
|
||||||
@@ -388,7 +391,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
|
|||||||
pageCacheMu.Lock()
|
pageCacheMu.Lock()
|
||||||
cached := pageCache[key]
|
cached := pageCache[key]
|
||||||
pageCacheMu.Unlock()
|
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
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +421,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
|
|||||||
if strings.TrimSpace(md) == "" {
|
if strings.TrimSpace(md) == "" {
|
||||||
return nil, fmt.Errorf("page vide")
|
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()
|
pageCacheMu.Lock()
|
||||||
pageCache[key] = entry
|
pageCache[key] = entry
|
||||||
pageCacheMu.Unlock()
|
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
|
// decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE
|
||||||
func decodeUddg(raw string) string {
|
func decodeUddg(raw string) string {
|
||||||
s := raw
|
s := raw
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ func cmdInternet(args []string) error {
|
|||||||
if err := setInternetEnabled(true); err != nil {
|
if err := setInternetEnabled(true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_open/web_read/web_grep (si le mode agent est actif)")
|
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_images/web_open/web_read/web_grep (si le mode agent est actif)")
|
||||||
case "off":
|
case "off":
|
||||||
if err := setInternetEnabled(false); err != nil {
|
if err := setInternetEnabled(false); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -352,7 +352,7 @@ func cmdInternet(args []string) error {
|
|||||||
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
||||||
if webEngine() == engineGo {
|
if webEngine() == engineGo {
|
||||||
fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
|
fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
|
||||||
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
|
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
|
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
|
||||||
@@ -366,7 +366,7 @@ func cmdInternet(args []string) error {
|
|||||||
reach = green("joignable")
|
reach = green("joignable")
|
||||||
}
|
}
|
||||||
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
|
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
|
||||||
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
|
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
|
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import (
|
|||||||
// L'IA y range ce qu'elle veut retenir entre les sessions (préférences,
|
// 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,
|
// décisions, procédures, infos projet). Quatre outils : mem_search, mem_read,
|
||||||
// mem_add, mem_edit.
|
// 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 {
|
type MemPage struct {
|
||||||
Name string // nom de fichier sans le dossier (ex: "docker-notes.md")
|
Name string // nom de fichier sans le dossier (ex: "docker-notes.md")
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ func prewarmNearMidnight(now time.Time) bool {
|
|||||||
|
|
||||||
// Points d'accroche remplaçables par les tests.
|
// Points d'accroche remplaçables par les tests.
|
||||||
var (
|
var (
|
||||||
|
// prewarmNow : l'heure du garde-fou de minuit.
|
||||||
|
prewarmNow = time.Now
|
||||||
// prewarmAsync : false = le préchauffage tourne dans l'appelant (tests).
|
// prewarmAsync : false = le préchauffage tourne dans l'appelant (tests).
|
||||||
prewarmAsync = true
|
prewarmAsync = true
|
||||||
// prewarmSlots : nombre de slots du moteur local (/props total_slots).
|
// 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.
|
// 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) {
|
func (c *Conversation) prewarmKick(why string) {
|
||||||
if prewarmMode(ReadConfig()) == "" {
|
if prewarmMode(ReadConfig()) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if facadeChat() {
|
||||||
|
go func() { _ = facadePost("/internal/facade/v1/chat/prewarm", map[string]any{"why": why}) }()
|
||||||
|
return
|
||||||
|
}
|
||||||
if prewarmAsync {
|
if prewarmAsync {
|
||||||
go c.prewarm(why)
|
go c.prewarm(why)
|
||||||
return
|
return
|
||||||
@@ -375,7 +383,7 @@ func (c *Conversation) prewarm(why string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(time.Now()) {
|
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(prewarmNow()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !healthCheck() {
|
if !healthCheck() {
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ func (m *moteurPrewarm) completions() []string {
|
|||||||
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
|
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
|
||||||
// préchauffage synchrone. keys : réglages posés.
|
// préchauffage synchrone. keys : réglages posés.
|
||||||
func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm {
|
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()
|
t.Helper()
|
||||||
withWorkspace(t)
|
withWorkspace(t)
|
||||||
activeProjMu.Lock()
|
activeProjMu.Lock()
|
||||||
|
|||||||
+105
-60
@@ -78,58 +78,88 @@ func recallIDToSeq(id string) (uint64, bool) {
|
|||||||
return n, true
|
return n, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
|
// recallArchiveInput : un bloc à archiver (voir archiveRecallBlocks).
|
||||||
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
|
type recallArchiveInput struct {
|
||||||
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
|
Label string
|
||||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
Role string
|
||||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
Content string
|
||||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
}
|
||||||
|
|
||||||
|
// 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
|
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||||
encode, err := memEncoderNow()
|
encode, err := memEncoderNow()
|
||||||
if err != nil {
|
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 {
|
err = withDB(func(d *bolt.DB) error {
|
||||||
return d.Update(func(tx *bolt.Tx) error {
|
return d.Update(func(tx *bolt.Tx) error {
|
||||||
|
entries = entries[:0]
|
||||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
seq, err := b.NextSequence()
|
for _, in := range blocks {
|
||||||
if err != nil {
|
seq, err := b.NextSequence()
|
||||||
return err
|
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{
|
return nil
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
cacheBust(bkRecall)
|
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.
|
// 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 {
|
if !ok {
|
||||||
return recallBlock{}, false
|
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
|
var blk recallBlock
|
||||||
found := false
|
found := false
|
||||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||||
@@ -155,6 +190,27 @@ func recallGet(id string) (recallBlock, bool) {
|
|||||||
return blk, found
|
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.
|
// 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é +
|
// 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).
|
// 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
|
score int
|
||||||
}
|
}
|
||||||
var hits []scored
|
var hits []scored
|
||||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
for _, blk := range recallAll() {
|
||||||
return b.ForEach(func(_, v []byte) error {
|
label := strings.ToLower(blk.Label)
|
||||||
var blk recallBlock
|
body := strings.ToLower(blk.Content)
|
||||||
dec, err := decodeMemContent(v)
|
score := 0
|
||||||
if err != nil {
|
for t := range terms {
|
||||||
return nil // chiffré et verrouillé : ce bloc ne participe pas
|
if strings.Contains(label, t) {
|
||||||
|
score += 2
|
||||||
}
|
}
|
||||||
if json.Unmarshal(dec, &blk) != nil {
|
if strings.Contains(body, t) {
|
||||||
return nil
|
score++
|
||||||
}
|
}
|
||||||
label := strings.ToLower(blk.Label)
|
}
|
||||||
body := strings.ToLower(blk.Content)
|
if score > 0 {
|
||||||
score := 0
|
hits = append(hits, scored{blk, score})
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
sort.Slice(hits, func(i, j int) bool {
|
sort.Slice(hits, func(i, j int) bool {
|
||||||
if hits[i].score != hits[j].score {
|
if hits[i].score != hits[j].score {
|
||||||
return 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,11 +41,15 @@ func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
testHome(t)
|
testHome(t)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
|
// Délai de 10 s, bien au-delà de WaitDelay (2 s) : c'est le détachement
|
||||||
if d := time.Since(start); d > 10*time.Second {
|
// 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)
|
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)
|
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.
|
// issue connue était de redémarrer loki-ui.
|
||||||
cmd.WaitDelay = 2 * time.Second
|
cmd.WaitDelay = 2 * time.Second
|
||||||
err := cmd.Run()
|
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
|
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
|
||||||
// demandait un tail.
|
// demandait un tail.
|
||||||
clean := func(s string) string { return tailOutput(stripANSI(s)) }
|
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 != "" {
|
if errOut != "" {
|
||||||
parts = append(parts, "stderr:\n"+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")
|
return strings.Join(parts, "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ func tplCapsCurrent() (tplProbeResult, bool) {
|
|||||||
if externalActive() {
|
if externalActive() {
|
||||||
return tplProbeResult{}, false
|
return tplProbeResult{}, false
|
||||||
}
|
}
|
||||||
|
if facadeChat() { // la façade sonde après SES complétions (facade_chat.go)
|
||||||
|
return facadeTplCaps()
|
||||||
|
}
|
||||||
tplProbeMu.Lock()
|
tplProbeMu.Lock()
|
||||||
defer tplProbeMu.Unlock()
|
defer tplProbeMu.Unlock()
|
||||||
if tplProbeLast == nil {
|
if tplProbeLast == nil {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
// web_images : la recherche d'images DuckDuckGo, pendant de web_search. Sans
|
||||||
|
// elle, « montre-moi des photos de X » obligeait le modèle à ouvrir des pages
|
||||||
|
// dans le navigateur et à en renvoyer des captures (lent, et des images de
|
||||||
|
// pages au lieu de photos).
|
||||||
|
//
|
||||||
|
// DuckDuckGo sert les images par i.js, qui exige un jeton « vqd » lu dans la
|
||||||
|
// page de recherche. Même client HTTP que le moteur intégré, quel que soit le
|
||||||
|
// moteur web actif : Crawl4AI n'apporterait rien à un appel JSON.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func webImagesTool() Tool {
|
||||||
|
return Tool{Type: "function", Function: ToolFunction{
|
||||||
|
// Schéma minimal : le préambule a un budget (chat_prompt_size_test.go).
|
||||||
|
// limit n'est pas annoncé ; toolWebImages l'accepte encore (6 par défaut).
|
||||||
|
Name: "web_images",
|
||||||
|
Description: "Find images.",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"query": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"query"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageResult struct {
|
||||||
|
Image string `json:"image"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", goFetchUA)
|
||||||
|
req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")
|
||||||
|
if referer != "" {
|
||||||
|
req.Header.Set("Referer", referer)
|
||||||
|
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||||
|
}
|
||||||
|
resp, err := goHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("échec de la requête : %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, goFetchMaxBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func duckduckgoImages(query string, limit int) ([]imageResult, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
page := ddgImagesBase + "/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
|
||||||
|
body, err := ddgGet(ctx, page, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := ddgVqdRe.FindSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Results []imageResult `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("réponse illisible : %v", err)
|
||||||
|
}
|
||||||
|
var out []imageResult
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, it := range r.Results {
|
||||||
|
if it.Image == "" || seen[it.Image] || !strings.HasPrefix(it.Image, "http") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[it.Image] = true
|
||||||
|
out = append(out, it)
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolWebImages(args map[string]any) string {
|
||||||
|
query, _ := args["query"].(string)
|
||||||
|
limit := 6
|
||||||
|
if v, ok := args["limit"].(float64); ok {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 1
|
||||||
|
}
|
||||||
|
if limit > 20 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
results, err := duckduckgoImages(query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return "❌ Recherche d'images échouée : " + err.Error()
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
return fmt.Sprintf("Aucune image pour « %s »", query)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "# Images : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
fmt.Fprintf(&b, "%d. %s (%dx%d)\n image : %s\n page : %s\n\n", i+1, r.Title, r.Width, r.Height, r.Image, r.URL)
|
||||||
|
}
|
||||||
|
b.WriteString("To show an image to the user, write  in your answer.")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ package loki
|
|||||||
//
|
//
|
||||||
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
|
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
|
||||||
// la discussion, sa suppression les emporte.
|
// 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 (
|
import (
|
||||||
"encoding/json"
|
"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,
|
// conversation. Best-effort : hors génération il n'y a pas d'epoch en cours,
|
||||||
// on publie sur l'epoch courant.
|
// on publie sur l'epoch courant.
|
||||||
func critNotify(list []Criterion) {
|
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
|
return
|
||||||
}
|
}
|
||||||
conv.mu.Lock()
|
conv.mu.Lock()
|
||||||
epoch := conv.epoch
|
epoch := conv.epoch
|
||||||
conv.mu.Unlock()
|
conv.mu.Unlock()
|
||||||
if list == nil {
|
conv.appendDelta(epoch, delta)
|
||||||
list = []Criterion{}
|
|
||||||
}
|
|
||||||
conv.appendDelta(epoch, map[string]any{"criteria": list, "ts": time.Now().UnixMilli()})
|
|
||||||
}
|
}
|
||||||
@@ -177,10 +177,25 @@ func toolBashTail(args map[string]any) string {
|
|||||||
return out + "\n" + tailRunes(tail, toolMaxOutput)
|
return out + "\n" + tailRunes(tail, toolMaxOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bgJobsRunning : jobs d'arrière-plan encore en vie. L'optimiseur refuse de
|
// bgJobsRunning : jobs d'arrière-plan encore en vie, ceux de la façade
|
||||||
// mesurer à côté d'eux : un build ou un serveur de dev prend le CPU et le
|
// compris quand elle tient la conversation (lot 43 : ses tours lancent les
|
||||||
// disque que mesurent ses essais.
|
// 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 {
|
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()
|
jobsMu.Lock()
|
||||||
defer jobsMu.Unlock()
|
defer jobsMu.Unlock()
|
||||||
n := 0
|
n := 0
|
||||||
|
|||||||
@@ -86,18 +86,26 @@ func convHasRepo() bool {
|
|||||||
// maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y
|
// 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.
|
// prête. Une seule fois par discussion : une suggestion répétée est du bruit.
|
||||||
func maybeCodeHint(c *Conversation, epoch int, text string) {
|
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() {
|
if convCodeMode() || !agentEnabled() {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
id := convEnsureActive()
|
id := convEnsureActive()
|
||||||
if getBool(bkChat, hintKey(id)) {
|
if getBool(bkChat, hintKey(id)) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if !looksLikeCode(text) && !convHasRepo() {
|
if !looksLikeCode(text) && !convHasRepo() {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
_ = putBool(bkChat, hintKey(id), true)
|
_ = putBool(bkChat, hintKey(id), true)
|
||||||
c.appendDelta(epoch, map[string]any{"code_hint": true})
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- API ---------------------------------------------------------------------
|
// --- 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
|
// Pousse la bascule à tous les appareils abonnés (le sélecteur doit
|
||||||
// suivre, comme le fil lui-même).
|
// suivre, comme le fil lui-même).
|
||||||
conv.mu.Lock()
|
if !facadeDelta(map[string]any{"mode": body.Mode}) {
|
||||||
epoch := conv.epoch
|
conv.mu.Lock()
|
||||||
conv.mu.Unlock()
|
epoch := conv.epoch
|
||||||
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
|
conv.mu.Unlock()
|
||||||
|
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
|
||||||
|
}
|
||||||
sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode})
|
sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-19
@@ -16,6 +16,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,39 +41,94 @@ var defaultRetryPatterns = []string{
|
|||||||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
// 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
|
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
|
||||||
// choisi son rôle (CLI, serveur…).
|
// 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 (
|
var (
|
||||||
retryOnce sync.Once
|
retryMu sync.Mutex
|
||||||
retryRes []*regexp.Regexp
|
retryRes []*regexp.Regexp
|
||||||
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
|
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
|
||||||
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
|
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
|
||||||
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
|
// 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 {
|
// retryStateBust : appelé par cacheBust pour toute écriture du bucket d'état.
|
||||||
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
|
func retryStateBust() {
|
||||||
return retryRes
|
retryMu.Lock()
|
||||||
|
retryStale = true
|
||||||
|
retryMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
|
func retryPatterns() []*regexp.Regexp {
|
||||||
pats := defaultRetryPatterns
|
res, _ := retryState()
|
||||||
custom := false
|
return res
|
||||||
// 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.
|
// retryState renvoie les motifs compilés et si ce sont ceux de la surcharge.
|
||||||
if raw := getStr(bkState, "retry_patterns"); raw != "" {
|
func retryState() ([]*regexp.Regexp, bool) {
|
||||||
var over []string
|
retryMu.Lock()
|
||||||
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
|
defer retryMu.Unlock()
|
||||||
pats, custom = over, true
|
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
|
var out []*regexp.Regexp
|
||||||
for _, p := range pats {
|
for _, p := range pats {
|
||||||
|
if strings.TrimSpace(p) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if re, err := regexp.Compile("(?mi)" + p); err == nil {
|
if re, err := regexp.Compile("(?mi)" + p); err == nil {
|
||||||
out = append(out, re)
|
out = append(out, re)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, custom
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
// 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 == "" {
|
if content == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
res := retryPatterns()
|
res, custom := retryState()
|
||||||
start := 0
|
start := 0
|
||||||
if !retryCustom {
|
if !custom {
|
||||||
start = retryScanStart(content, scanned)
|
start = retryScanStart(content, scanned)
|
||||||
}
|
}
|
||||||
w := content[start:]
|
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"
|
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}
|
caps, system := subagentView(role)
|
||||||
// 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."
|
|
||||||
}
|
|
||||||
msgs := []Message{
|
msgs := []Message{
|
||||||
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
|
{Role: "system", Content: system},
|
||||||
{Role: "user", Content: task + framing},
|
{Role: "user", Content: task + subagentFraming(role)},
|
||||||
}
|
}
|
||||||
var out strings.Builder
|
var out strings.Builder
|
||||||
// Le sous-agent occupe le slot de la conversation du builder : une fois son
|
// 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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,6 +225,7 @@ func cdpLaunch() (*cdpSession, error) {
|
|||||||
args = append([]string{"--headless=new"}, args...)
|
args = append([]string{"--headless=new"}, args...)
|
||||||
}
|
}
|
||||||
cmd := exec.Command(bin, args...)
|
cmd := exec.Command(bin, args...)
|
||||||
|
prepareBoundChild(cmd) // tué avec Loki, même brutalement (sys_childbind_*.go)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
os.RemoveAll(userDir)
|
os.RemoveAll(userDir)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -456,6 +457,7 @@ func sweepStaleCUDirs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cdpShutdown() {
|
func cdpShutdown() {
|
||||||
|
facadeBrowserShutdown() // lot 44 : celui de la façade, quand elle tient la conversation
|
||||||
cdpMu.Lock()
|
cdpMu.Lock()
|
||||||
defer cdpMu.Unlock()
|
defer cdpMu.Unlock()
|
||||||
if cdpCur != nil {
|
if cdpCur != nil {
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ func cuPromptLine() string {
|
|||||||
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
|
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
|
||||||
|
|
||||||
func toolCUOpen(args map[string]any) string {
|
func toolCUOpen(args map[string]any) string {
|
||||||
|
if r, _, ok := facadeBrowserTool("browser_open", args); ok {
|
||||||
|
return r
|
||||||
|
}
|
||||||
url, _ := args["url"].(string)
|
url, _ := args["url"].(string)
|
||||||
if strings.TrimSpace(url) == "" {
|
if strings.TrimSpace(url) == "" {
|
||||||
return "[erreur] url manquante"
|
return "[erreur] url manquante"
|
||||||
@@ -188,6 +191,9 @@ func toolCUOpen(args map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toolCUSnapshot() string {
|
func toolCUSnapshot() string {
|
||||||
|
if r, _, ok := facadeBrowserTool("browser_snapshot", map[string]any{}); ok {
|
||||||
|
return r
|
||||||
|
}
|
||||||
s, err := cdpGet()
|
s, err := cdpGet()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "[erreur] " + err.Error()
|
return "[erreur] " + err.Error()
|
||||||
@@ -200,6 +206,9 @@ func toolCUSnapshot() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toolCUFind(args map[string]any) string {
|
func toolCUFind(args map[string]any) string {
|
||||||
|
if r, _, ok := facadeBrowserTool("browser_find", args); ok {
|
||||||
|
return r
|
||||||
|
}
|
||||||
text, _ := args["text"].(string)
|
text, _ := args["text"].(string)
|
||||||
if strings.TrimSpace(text) == "" {
|
if strings.TrimSpace(text) == "" {
|
||||||
return "[erreur] texte de recherche manquant"
|
return "[erreur] texte de recherche manquant"
|
||||||
@@ -216,6 +225,9 @@ func toolCUFind(args map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toolCUClick(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")
|
ref, ok := intArg(args, "ref")
|
||||||
if !ok {
|
if !ok {
|
||||||
return "[erreur] ref manquant (numéro de l'élément)"
|
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 {
|
func toolCUType(args map[string]any) string {
|
||||||
|
if r, _, ok := facadeBrowserTool("browser_type", args); ok {
|
||||||
|
return r
|
||||||
|
}
|
||||||
text, _ := args["text"].(string)
|
text, _ := args["text"].(string)
|
||||||
s, err := cdpGet()
|
s, err := cdpGet()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -249,6 +264,9 @@ func toolCUType(args map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toolCUKey(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)
|
key, _ := args["key"].(string)
|
||||||
s, err := cdpGet()
|
s, err := cdpGet()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -262,6 +280,9 @@ func toolCUKey(args map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toolCUClickXY(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")
|
x, okx := floatArg(args, "x")
|
||||||
y, oky := floatArg(args, "y")
|
y, oky := floatArg(args, "y")
|
||||||
if !okx || !oky {
|
if !okx || !oky {
|
||||||
@@ -279,6 +300,9 @@ func toolCUClickXY(args map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toolCUScroll(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)
|
dir, _ := args["direction"].(string)
|
||||||
s, err := cdpGet()
|
s, err := cdpGet()
|
||||||
if err != nil {
|
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
|
// 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.
|
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
|
||||||
func toolCUScreenshot() (string, map[string]any) {
|
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() {
|
if !visionEnabled() {
|
||||||
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
|
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
// cu_autoshot.go — aperçu EN DIRECT du navigateur piloté (computer use). Après
|
||||||
|
// chaque action browser_*, on capture la page pour que l'UTILISATEUR voie ce que
|
||||||
|
// fait l'IA. C'est de l'interface seulement :
|
||||||
|
// - jamais envoyé au modèle (zéro token, vision non requise) ;
|
||||||
|
// - jamais écrit sur le disque : un petit anneau en mémoire vive ;
|
||||||
|
// - retiré du journal en fin de tour (compactLogLocked), et l'UI efface la carte
|
||||||
|
// à la fin du tour. Une capture à GARDER passe toujours par
|
||||||
|
// browser_screenshot, qui l'enregistre dans le dossier de travail.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cuShotRing = 8 // captures gardées en RAM (l'UI n'affiche que la dernière)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cuShotMu sync.Mutex
|
||||||
|
cuShotData = map[string][]byte{}
|
||||||
|
cuShotIDs []string
|
||||||
|
)
|
||||||
|
|
||||||
|
// cuAutoShotTool : outils après lesquels on capture (ceux qui changent la page).
|
||||||
|
func cuAutoShotTool(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "browser_open", "browser_click", "browser_click_xy", "browser_type", "browser_key", "browser_scroll", "browser_find":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n", a...) }
|
||||||
|
|
||||||
|
// cuAutoShot capture la page en pleine résolution (JPEG de bonne qualité) et
|
||||||
|
// 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)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, err := s.screenshot()
|
||||||
|
if err != nil {
|
||||||
|
cuLog("aperçu : capture impossible : %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
cuLog("aperçu : image illisible (%d octets) : %v", len(raw), err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
cuShotMu.Lock()
|
||||||
|
cuShotData[id] = buf.Bytes()
|
||||||
|
cuShotIDs = append(cuShotIDs, id)
|
||||||
|
for len(cuShotIDs) > cuShotRing {
|
||||||
|
delete(cuShotData, cuShotIDs[0])
|
||||||
|
cuShotIDs = cuShotIDs[1:]
|
||||||
|
}
|
||||||
|
cuShotMu.Unlock()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCUShot (GET ?id=) : une capture en base64 (JSON, pour traverser aussi le
|
||||||
|
// tunnel chiffré du relais). 404 si elle est déjà sortie de l'anneau.
|
||||||
|
func handleCUShot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||||
|
cuShotMu.Lock()
|
||||||
|
b := cuShotData[id]
|
||||||
|
cuShotMu.Unlock()
|
||||||
|
if b == nil {
|
||||||
|
sendJSON(w, 404, map[string]any{"ok": false, "error": "capture expirée"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true, "mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(b)})
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// L'aperçu du navigateur (shot) n'est que du direct : la compaction de fin de
|
||||||
|
// tour le retire du journal, le reste de l'événement d'outil est gardé.
|
||||||
|
func TestCompactLogRetireLAperçuNavigateur(t *testing.T) {
|
||||||
|
log := []LogEvent{
|
||||||
|
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": false}}},
|
||||||
|
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": true, "result": "ok", "shot": "abc"}}},
|
||||||
|
}
|
||||||
|
out := compactLog(log)
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("un seul événement d'outil attendu, %d", len(out))
|
||||||
|
}
|
||||||
|
tu := out[0].Delta["tool_used"].(map[string]any)
|
||||||
|
if _, has := tu["shot"]; has {
|
||||||
|
t.Fatal("l'aperçu aurait dû quitter le journal")
|
||||||
|
}
|
||||||
|
if tu["result"] != "ok" || tu["name"] != "browser_click" {
|
||||||
|
t.Fatalf("événement abîmé : %v", tu)
|
||||||
|
}
|
||||||
|
if _, has := log[1].Delta["tool_used"].(map[string]any)["shot"]; !has {
|
||||||
|
t.Fatal("le journal d'origine ne doit pas être modifié en place")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCUAutoShotTool(t *testing.T) {
|
||||||
|
for _, n := range []string{"browser_open", "browser_click", "browser_type", "browser_scroll"} {
|
||||||
|
if !cuAutoShotTool(n) {
|
||||||
|
t.Errorf("%s devrait déclencher l'aperçu", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range []string{"browser_screenshot", "browser_snapshot", "bash", "web_images"} {
|
||||||
|
if cuAutoShotTool(n) {
|
||||||
|
t.Errorf("%s ne devrait pas déclencher l'aperçu", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -414,6 +414,10 @@ func asrShutdown() {
|
|||||||
// normale. La dictée qui attendait reçoit une erreur de démarrage — c'est
|
// 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.
|
// l'utilisateur qui vient de demander la place, pas une panne.
|
||||||
func asrShutdownVite() {
|
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++ {
|
for i := 0; i < 20; i++ {
|
||||||
if wsrvMu.TryLock() {
|
if wsrvMu.TryLock() {
|
||||||
asrStopLocked()
|
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()
|
||||||
|
}
|
||||||
Loaded 100 of 486 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user