mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Compare commits
153
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6911af27 | ||
|
|
965a4d6247 | ||
|
|
99c96a6c33 | ||
|
|
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 | ||
|
|
502f23e833 | ||
|
|
634cfb61f3 | ||
|
|
ecff7de5a3 | ||
|
|
127013b32b | ||
|
|
4a9a5a1712 | ||
|
|
5dd14c9de5 | ||
|
|
5d37bef0ce | ||
|
|
5b834d54b2 | ||
|
|
c24472b91f | ||
|
|
911ffbfeaf | ||
|
|
5f7203a935 | ||
|
|
4b659ac186 | ||
|
|
ac40a2556a | ||
|
|
a325e98dad | ||
|
|
f0fef91773 | ||
|
|
a4db6d4da1 | ||
|
|
d50c8a56c6 | ||
|
|
432308c5b2 | ||
|
|
c888afb493 |
No files matched your search
@@ -8,3 +8,4 @@ dist/
|
||||
!README.md
|
||||
.env
|
||||
.env.*
|
||||
rust/target/
|
||||
@@ -31,6 +31,24 @@ jobs:
|
||||
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
|
||||
done
|
||||
|
||||
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: rust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rust
|
||||
- run: cargo fmt --all --check
|
||||
- run: cargo clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo test --locked
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
LOKI_VERSION=${{ github.sha }}
|
||||
LOKI_BUILD=${{ github.run_number }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
|
||||
@@ -61,3 +62,56 @@ jobs:
|
||||
# pas : seul le code Go et l'assemblage de l'image se refont.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Release « rolling » : les binaires que le conteneur installe lui-même au
|
||||
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
|
||||
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
|
||||
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
|
||||
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
|
||||
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
|
||||
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
|
||||
# voit pas.
|
||||
rolling:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: rolling-release
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-musl
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: rust
|
||||
- name: Outils musl
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
|
||||
- name: Binaires
|
||||
run: |
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
|
||||
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
|
||||
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
|
||||
file dist/loki-rs-linux
|
||||
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
|
||||
abi=$(tr -d '[:space:]' < IMAGE_ABI)
|
||||
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
|
||||
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
|
||||
cat dist/manifest.json dist/SHA256SUMS
|
||||
- name: Publier
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release view rolling >/dev/null 2>&1 || \
|
||||
gh release create rolling --prerelease --target "$GITHUB_SHA" \
|
||||
--title "rolling (mise à jour au démarrage)" \
|
||||
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
|
||||
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
|
||||
gh release upload rolling dist/manifest.json --clobber
|
||||
@@ -22,3 +22,6 @@ Thumbs.db
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
node_modules/
|
||||
|
||||
# Rust (façade loki-rs)
|
||||
rust/target/
|
||||
+32
-1
@@ -42,6 +42,23 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
|
||||
# limité à cette étape de build.
|
||||
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
||||
|
||||
# ── Étape 1 ter : façade Rust loki-rs ───────────────────────────────────
|
||||
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
|
||||
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
|
||||
# le même que celui publié dans la release « rolling » et installé à chaud
|
||||
# dans /data/bin par `loki-rs self-update`.
|
||||
FROM rust:1-alpine AS rustbuild
|
||||
RUN apk add --no-cache musl-dev gcc
|
||||
WORKDIR /src
|
||||
# L'UI est embarquée par build.rs depuis la même source que le go:embed : les
|
||||
# deux binaires d'une image servent toujours la même interface.
|
||||
COPY internal/loki/ui/ internal/loki/ui/
|
||||
# Catalogue MCP embarqué par loki-rs (lot 20), le même que le Go.
|
||||
COPY internal/loki/mcp_catalog.json internal/loki/mcp_catalog.json
|
||||
COPY rust/ rust/
|
||||
RUN cd rust && cargo build --release --locked \
|
||||
&& mkdir -p /out && cp target/release/loki-rs /out/loki-rs
|
||||
|
||||
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
|
||||
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
|
||||
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
|
||||
@@ -87,8 +104,12 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
|
||||
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
|
||||
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
|
||||
# et la plupart des serveurs MCP réclament Node ≥ 20.
|
||||
# python3 + python3-venv : l'installeur et le serveur du moteur Strata
|
||||
# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans
|
||||
# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés
|
||||
# à la demande depuis Paramètres → Moteur.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git tini ca-certificates curl gnupg \
|
||||
git tini ca-certificates curl gnupg python3 python3-venv \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -127,6 +148,7 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
||||
|
||||
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
||||
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
|
||||
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
|
||||
# l'image : téléchargé à la demande dans /data/asr/.
|
||||
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
|
||||
@@ -143,6 +165,15 @@ ENV LOKI_CONTAINER=1 \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
|
||||
# dépendances d'image les binaires exigent — un binaire publié pour une autre
|
||||
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
|
||||
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
|
||||
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
|
||||
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
|
||||
ARG LOKI_BUILD=0
|
||||
ENV LOKI_BUILD=${LOKI_BUILD}
|
||||
|
||||
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
|
||||
# conteneur — il n'est pas authentifié par défaut.
|
||||
EXPOSE 8090
|
||||
|
||||
@@ -52,6 +52,33 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
Le **rendu des formules LaTeX** dans les réponses (`ui/src/js/00-math.js`,
|
||||
`web_katex.go`) est repris d'AJEAN (#123) : $…$, $$…$$, \\(…\\) et \\[…\\] rendus
|
||||
par KaTeX, chargé à la première formule seulement, sans toucher au code ni aux
|
||||
montants en $. Loki y ajoute deux garde-fous de détection (« 5 $, » et un $
|
||||
voisin d'un code en ligne restent du texte) et sert la route `/katex/` aussi
|
||||
depuis la façade Rust (`rust/loki-rs/src/ui.rs`).
|
||||
|
||||
## KaTeX
|
||||
|
||||
**[KaTeX](https://katex.org/)** 0.16.11 (licence MIT, © Khan Academy et
|
||||
contributeurs) est embarqué tel que publié — script, feuille de style et
|
||||
polices woff2 — sous `internal/loki/ui/katex/`, avec sa licence
|
||||
(`internal/loki/ui/katex/LICENSE`). Il est servi par Loki sous `/katex/`, sans
|
||||
appel à un CDN.
|
||||
|
||||
## Moteur Strata
|
||||
|
||||
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
|
||||
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
|
||||
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.1 »,
|
||||
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
|
||||
release `moe-v1.1` (sources de Strata et binaire Linux CUDA 12 compilé, avec
|
||||
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
|
||||
par AJEAN. La reprise automatique d'une installation 1.0 (`strataMigrate`) est
|
||||
reprise d'AJEAN (`moeMigrate`). Ni Strata ni le modèle ne sont inclus dans
|
||||
l'image : ils sont téléchargés à la demande.
|
||||
|
||||
## Services externes
|
||||
|
||||
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
|
||||
|
||||
@@ -16,7 +16,9 @@ seule image.
|
||||
|
||||
```
|
||||
┌────────────────── conteneur loki ──────────────────┐
|
||||
│ loki web (UI + API, port 8090, premier plan) │
|
||||
│ loki-rs serve (façade Rust, port 8090) │
|
||||
│ │ relaie HTTP/SSE/WebSocket │
|
||||
│ loki web (UI + API Go, 127.0.0.1:8091) │
|
||||
│ │ pilote (fichier PID — pas de systemd) │
|
||||
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
||||
│ ▲ modèles .gguf │
|
||||
@@ -35,6 +37,13 @@ seule image.
|
||||
redémarre le moteur normalement.
|
||||
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
|
||||
seule l'UI (8090) l'est.
|
||||
- **Migration vers Rust en cours**, par lots : la façade `loki-rs`
|
||||
(`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot
|
||||
lui fait reprendre des routes. Déjà servis par Rust : l'interface (HTML,
|
||||
JS, polices) et `/api/ping`, avec l'authentification de pilotage portée à
|
||||
l'identique (la clé est demandée au Go par une API interne locale,
|
||||
protégée par un jeton tiré à chaque démarrage). Plan et feuille de route :
|
||||
`docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
|
||||
|
||||
## Démarrage rapide (Docker, GPU NVIDIA)
|
||||
|
||||
@@ -93,6 +102,89 @@ la recherche qu'au téléchargement. À défaut, la variable d'environnement
|
||||
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
|
||||
n'est envoyé qu'aux adresses Hugging Face.
|
||||
|
||||
## Choisir son moteur : llama.cpp ou Strata
|
||||
|
||||
Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
|
||||
|
||||
- **llama.cpp** (par défaut) — n'importe quel GGUF, réglages complets dans
|
||||
l'éditeur de preset.
|
||||
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
|
||||
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
|
||||
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
|
||||
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.1 », vérifié par
|
||||
SHA-256).
|
||||
|
||||
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
|
||||
disque), propose la version (Classique ou Swift 1.5) et la qualité qui tient,
|
||||
puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go dans
|
||||
`/data/strata`). Le modèle installé devient un preset comme les autres :
|
||||
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
|
||||
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
|
||||
cache KV fp16/8 bits, prédiction MTP, lecture des images, seconde carte).
|
||||
|
||||
La seconde carte a trois réglages : **non utilisée**, **aide aux experts** (le
|
||||
défaut : elle garde et calcule une part des experts) ou **partage des couches**
|
||||
(les deux cartes se répartissent les couches du modèle). Le partage est souvent
|
||||
plus rapide avec deux cartes identiques (+50 % en génération mesuré par un
|
||||
utilisateur d'AJEAN sur deux RTX 3060) ; sur deux cartes très différentes,
|
||||
l'aide aux experts reste le meilleur choix. En partage des couches, le mode
|
||||
« hors RAM » n'est jamais choisi : la seconde carte n'y garde pas d'experts.
|
||||
|
||||
Depuis le paquet 1.1, la carte d'appoint calcule elle-même ses experts à la
|
||||
lecture des prompts (au lieu de les recopier vers la principale) : les ajouts
|
||||
de 100 à 200 jetons se lisent ~30 à 40 % plus vite (mesures d'AJEAN). Un modèle
|
||||
installé avec la 1.0 passe à la 1.1 **à son premier lancement**, sans
|
||||
réinstallation : Loki télécharge le nouveau paquet (~340 Mo), reprend
|
||||
l'environnement Python et la configuration de la 1.0 (le dossier
|
||||
`/data/strata/ajean-moe-1.0` doit donc rester en place) et réécrit le preset.
|
||||
En cas d'échec, la 1.0 continue de tourner et la raison est dans le journal du
|
||||
moteur. En mode « hors RAM », le contexte est plafonné à 131 072 jetons (au-delà,
|
||||
le cache KV verrouillé en RAM ne tient plus).
|
||||
|
||||
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
|
||||
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
|
||||
la plus petite qualité, pilote compatible CUDA 12.8. Le conteneur doit pouvoir
|
||||
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'à
|
||||
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
|
||||
|
||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||
@@ -108,6 +200,32 @@ Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose
|
||||
`docker-compose.unraid.yml` → **Compose Up**.
|
||||
4. Interface : `http://<ip-unraid>:8090`.
|
||||
|
||||
## Mise à jour au démarrage
|
||||
|
||||
Un **simple redémarrage du conteneur** (ou du serveur Unraid) suffit à passer
|
||||
à la dernière version de Loki : pas besoin de `docker compose pull` pour les
|
||||
changements de code.
|
||||
|
||||
- À chaque push sur `main`, la CI publie la release GitHub
|
||||
[`rolling`](https://github.com/R0m1k3/Loki/releases/tag/rolling) : binaires
|
||||
`loki` (Go) et `loki-rs` (Rust), `SHA256SUMS`, `manifest.json`.
|
||||
- Au démarrage, `loki-rs self-update` les télécharge, vérifie leur empreinte
|
||||
SHA-256 et les installe dans `/data/bin/<build>/`. Le journal du conteneur
|
||||
dit ce qui s'est passé (`[loki-rs update] …`, puis
|
||||
`[entrypoint] binaires Loki : …`).
|
||||
- **Jamais bloquant** : réseau coupé, empreinte fausse, GitHub injoignable →
|
||||
les binaires déjà en place servent, le conteneur démarre normalement.
|
||||
- **Quand une nouvelle image est nécessaire** (nouvelle dépendance système,
|
||||
Python, Node, llama.cpp…), le fichier `IMAGE_ABI` est incrémenté : les
|
||||
conteneurs plus anciens n'installent pas ces binaires et le journal demande
|
||||
un `docker compose pull`.
|
||||
- La mise à jour est faite par le `loki-rs` **de l'image**, jamais par celui
|
||||
téléchargé : une version publiée défectueuse ne peut pas empêcher le
|
||||
correctif suivant d'arriver au redémarrage d'après.
|
||||
- `docker exec loki loki …` utilise aussi la version installée.
|
||||
- Couper : `LOKI_AUTO_UPDATE=0`. Revenir aux binaires de l'image : supprimer
|
||||
`/data/bin` et redémarrer avec `LOKI_AUTO_UPDATE=0`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
|
||||
@@ -125,6 +243,29 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
|
||||
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
|
||||
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
|
||||
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
|
||||
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
|
||||
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
|
||||
| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) les images gardées (`KEEP_TURN_IMAGES`), le préchauffage (`PREWARM`) et le bloc projet figé (`PROJ_SNAPSHOT`). `go` : la rend au Go (porte de sortie) | Rust |
|
||||
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent), et les outils mémoire de l'agent (mem_search, mem_read, mem_add, mem_edit, tracker) dans les tours de la façade, sont tenus par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust, qui compile aussi llama.cpp depuis les sources sous Linux et macOS ; les jobs du Go (compilation sous Windows, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
|
||||
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust (expérimental, migration en cours) ; le navigateur piloté, lui, est celui de la façade dès qu'elle tient la conversation | Go |
|
||||
| `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go |
|
||||
|
||||
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
|
||||
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
|
||||
@@ -356,6 +497,242 @@ Ajoutées par ce fork :
|
||||
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
|
||||
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
|
||||
raisonnement est coupé pour ce modèle.
|
||||
- **Performance** : cache de prompts, slots, décodage spéculatif, placement
|
||||
MoE et multi-GPU, optimiseur — tout est rassemblé dans la section
|
||||
[Performance](#performance), avec la table des clés et leur défaut.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les
|
||||
chemins relatifs du modèle y sont résolus. Changer de discussion change donc
|
||||
les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que
|
||||
le disque ne se remplisse pas en silence.
|
||||
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
|
||||
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
|
||||
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
|
||||
(Chromium via Playwright, inclus dans l'image). Les captures partent en JPEG
|
||||
et sont plafonnées à 20 fichiers / 40 Mo par discussion. La description de
|
||||
l'outil suit la capacité **réelle** du moteur, sondée sur `/props` : sans
|
||||
vision effective, elle dit au modèle « tu ne vois pas l'image » plutôt que de
|
||||
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
|
||||
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
|
||||
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
|
||||
- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
|
||||
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
|
||||
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
|
||||
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
|
||||
supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
|
||||
sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le
|
||||
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
|
||||
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
|
||||
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
|
||||
- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
|
||||
et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
|
||||
aucune requête vers un service de polices. Deux variantes : claire par défaut,
|
||||
**Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
|
||||
L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
|
||||
changement de preset ne demande plus d'ouvrir les réglages) ; la barre
|
||||
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
|
||||
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
|
||||
visibles en pied de colonne.
|
||||
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
|
||||
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
|
||||
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
|
||||
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
|
||||
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
|
||||
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
|
||||
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
|
||||
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
|
||||
du moteur. Conséquence directe : l'API est joignable partout où l'interface
|
||||
l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de
|
||||
second port ni ouvrir le moteur. L'amont annonçait `http://<ip>:8080/v1`, une
|
||||
adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP
|
||||
détectée est celle du bridge Docker).
|
||||
- Authentification par la **clé API** du panneau (`Authorization: Bearer …`),
|
||||
vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et
|
||||
l'interface le dit en rouge.
|
||||
- **Adresse publique** : un champ où saisir son domaine, pour le cas du
|
||||
reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse
|
||||
affichée suit celle du navigateur.
|
||||
- **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki
|
||||
honore `X-Forwarded-Proto` pour annoncer une adresse en `https`.
|
||||
- L'ancienne exposition publique via le relais de l'amont
|
||||
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
|
||||
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
|
||||
pouvait donc qu'échouer.
|
||||
- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une
|
||||
recherche légitime est pire que la laisser durer — mais au-delà de 24 appels
|
||||
sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui
|
||||
demande de conclure. Le rappel revient tous les 24 appels, en durcissant le
|
||||
ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière.
|
||||
Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf
|
||||
le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois
|
||||
les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` —
|
||||
`AGENT_BUDGET=off` le désactive complètement.
|
||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||
dans le fil.
|
||||
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
|
||||
volets — la nav des sections à gauche (IA, moteur, application), le panneau
|
||||
choisi à droite. La barre latérale ne garde que les discussions (les plus
|
||||
récentes en tête) et le moniteur machine.
|
||||
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
|
||||
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
|
||||
survit au rechargement, et un vieux tour garde le modèle de l'époque.
|
||||
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
|
||||
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
|
||||
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
|
||||
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
|
||||
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
|
||||
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
|
||||
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
|
||||
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
|
||||
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
|
||||
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
|
||||
l'affichage ne se fige plus.
|
||||
|
||||
Retirées par ce fork :
|
||||
|
||||
- **Accès distant via [ajean.link](https://ajean.link)** : la section de
|
||||
l'interface et son module JS sont supprimés — un conteneur derrière son
|
||||
propre réseau n'en a pas l'usage. Le code serveur du relais reste en place
|
||||
mais **inerte** (aucun jeton, aucune section pour en fournir un) : le retirer
|
||||
créerait un conflit à chaque reprise de l'amont.
|
||||
- **Postes distants** (faire agir l'agent sur un autre PC appairé) : bouton du
|
||||
composeur, modales d'appairage et module JS supprimés. Même traitement que
|
||||
ci-dessus — les routes `/api/node/*` subsistent mais plus rien ne peut
|
||||
générer de code d'appairage, donc aucun poste ne peut se connecter.
|
||||
- **Catalogue de modèles distant** : il interrogeait `ajean.link/models.json`,
|
||||
sa route n'avait aucun consommateur et son repli embarqué datait de 2024. La
|
||||
recherche Hugging Face le remplace.
|
||||
|
||||
## Performance
|
||||
|
||||
Le but : des tours plus courts **sans dénaturer le modèle**. Rien ici ne
|
||||
touche à la quantification des poids, à l'échantillonnage, à la température, au
|
||||
budget de raisonnement ni au contenu du contexte. Tout ce qui changerait ce
|
||||
qu'envoie Loki au modèle, le placement des poids ou les slots du moteur est
|
||||
**désactivé par défaut**, derrière une clé ; sans clé, la requête et la ligne de
|
||||
commande restent celles d'avant. Les clés se posent dans le preset (éditeur) ou
|
||||
par `loki config set CLÉ valeur` ; celles marquées « moteur relancé » demandent
|
||||
un redémarrage du moteur.
|
||||
|
||||
### Actif par défaut, sans perte
|
||||
|
||||
- **Cache de prompts dimensionné** (`--cache-ram`) : agrandi pour un modèle
|
||||
tout-GPU d'après l'état mesuré dans le GGUF, jamais sous le défaut du moteur ;
|
||||
et le slot est effacé après un sous-agent, une vérification, une tâche ou un
|
||||
bench, pour que la conversation revienne du cache RAM au lieu d'être
|
||||
recalculée.
|
||||
- **Préfixe stable** : rappels de Loki gardés dans l'historique tels
|
||||
qu'envoyés, relance après un 500 sans toucher aux outils ni au système, ligne
|
||||
MCP présente dès le premier tour, ordre des trackers fixe.
|
||||
- **Comptage du contexte** sans le raisonnement jamais renvoyé : la compaction
|
||||
(avec perte) part moins tôt, et son résumé n'est plus amputé de l'état
|
||||
d'avancement.
|
||||
- **Threads CPU** : sans `THREADS`, le moteur prend ses cœurs physiques (plus
|
||||
tous les threads logiques) ; conteneur à l'étroit (cpuset, quota) borné.
|
||||
- **Deux GPU** : `CUDA_SCALE_LAUNCH_QUEUES=4x` quand le pipeline entre cartes
|
||||
est possible (même calcul, seul le prompt peut gagner).
|
||||
- **Points de reprise des hybrides** sur un moteur officiel antérieur à
|
||||
`b10864` : `--checkpoint-min-step 2048` d'office si la RAM le permet, et un
|
||||
avis « moteur trop ancien » ; l'encart « version recommandée » propose la mise
|
||||
à jour (sur clic seulement, voir [Mettre à jour llama.cpp](#mettre-à-jour-llamacpp-sans-reconstruire-limage)).
|
||||
- **Côté Go** : écriture d'un gros fichier en temps linéaire, persistance de la
|
||||
discussion hors du chemin du premier jeton, un seul `nvidia-smi` partagé
|
||||
(moniteur et éditeur de preset), stockage Unraid servi par FUSE signalé.
|
||||
- **Mesure** : télémétrie par complétion (cache repris, recalculé, brouillon
|
||||
accepté) dans l'interface et `GET /api/perf/summary`, sonde du gabarit de
|
||||
chat (lecture seule, `/apply-template`), bench honnête en tâche de fond.
|
||||
- **Garde-fous** : un cache KV quantifié, `--context-shift` ou `--cache-reuse`
|
||||
posés à la main sont signalés ; un mode de chargement résident voué à
|
||||
l'échec est refusé (`LOAD_GUARD`) ; avis de placement MoE dans l'éditeur.
|
||||
|
||||
### Les clés
|
||||
|
||||
| Clé | Défaut | Effet | Prix, zone grise |
|
||||
|---|---|---|---|
|
||||
| `CACHE_RAM` | auto | cache de prompts en RAM hôte (`--cache-ram`, Mio), copie exacte des conversations quittées | `-1` = RAM non bornée (déconseillé avec un MoE mmappé), `0` = coupé |
|
||||
| `CACHE_ISOLATE` | actif | efface le slot après un travail annexe (sous-agent, vérification, tâche, bench) | `off` pour couper ; sans objet avec `SIDE_SLOT` |
|
||||
| `THREADS` / `THREADS_BATCH` | vide = cœurs physiques | `-t` / `-tb` | — |
|
||||
| `CUDA_LAUNCH_QUEUES` | auto (4x sur ≥ 2 GPU si pipeline) | file de lancements CUDA | `off`, ou `0.25x`…`4x` imposé ; réglage de machine |
|
||||
| `CTX_CHECKPOINTS` | moteur (32) | points de reprise par slot d'un hybride | 70 à 200 Mio de RAM hôte chacun |
|
||||
| `CKPT_MIN_STEP` | moteur (2048 d'office : hybride, moteur officiel < `b10864`) | espacement minimal des points de reprise | — |
|
||||
| `LOAD_GUARD` | actif | refuse un `--load-mode` résident qui ne tient pas en RAM | `off` = lancer quand même |
|
||||
| `REASONING_ECHO` | off | renvoie au moteur local la réflexion du modèle, au format entraîné | plus de contexte par tour, compaction plus tôt |
|
||||
| `REASONING_PRESERVE` | vide = défaut du moteur | `--reasoning-preserve` / `--no-reasoning-preserve` | change le rendu : `off` rend l'ancien historique à Qwen3.6, mais change celui de Qwen3.8 |
|
||||
| `PROJ_SNAPSHOT` | off | bloc projet figé par discussion, changements en `<context_update>` ; date et dossier sortent du système | le modèle lit les changements en tête du message suivant |
|
||||
| `PREWARM` | off | `on` : prépare le prochain tour pendant que tu lis ; `full` : aussi la discussion qu'on ouvre | une requête de plus au moteur au repos (1 jeton, jeté) |
|
||||
| `COMPACT_CONTINUATION` | off | le résumé de compaction prolonge le prompt en cache au lieu d'une transcription à froid | repli sur l'ancien chemin au moindre écart |
|
||||
| `KEEP_TURN_IMAGES` | off | garde dans l'historique les images montrées par les outils | **zone grise** : plus de contexte, compaction plus tôt ; une API externe refacture les images |
|
||||
| `NUDGE_IN_TOOL` | off | le rappel de budget d'outils part au bout du dernier résultat d'outil | **zone grise** : un modèle peut moins bien suivre une consigne lue dans un outil |
|
||||
| `SIDE_SLOT` | off (moteur relancé) | second slot pour les travaux annexes, la discussion garde le sien | cache KV et état récurrent en double en VRAM |
|
||||
| `SLOT_PERSIST` | off | état du slot gardé sur disque à la bascule de preset, rechargé au retour | 2 fichiers de 8 Gio au plus ; refusé avec `SPEC` |
|
||||
| `SPEC` | off (moteur relancé) | décodage spéculatif : `auto`, `mtp`, `ngram`, `mtp+ngram` | même distribution, pas le même texte au bit près ; MTP : 1 à 2 Go de VRAM |
|
||||
| `MODEL_DRAFT` | — | tête MTP publiée à part ou petit modèle brouillon | VRAM |
|
||||
| `SPEC_N_MAX` | moteur (3) | jetons anticipés par étape | — |
|
||||
| `SPEC_SAMPLING` | greedy (exact) | `probabilistic` seulement avec `SPEC=mtp` ou `mtp+ngram` | — |
|
||||
| `SPLIT_MODE` | couches | `tensor` : parallélisme de tenseurs entre cartes CUDA | expérimental : décodage plus rapide, prefill plus lent |
|
||||
| `FIT_TARGET` | 1024 Mio par carte | marge laissée libre par le placement auto (`--fit-target`) | — |
|
||||
| `OP_OFFLOAD_MIN_BATCH` | moteur (32) | lot à partir duquel les experts MoE sur CPU sont recopiés vers le GPU | à mesurer |
|
||||
| `CUDA_GRAPH_OPT` | off | branches Q/K/V en parallèle au décodage | expérimental ; `off` puis redémarrage s'il plante |
|
||||
| `KV_TYPE` (`_K`, `_V`) | f16 | quantification du cache KV | **zone grise** : `q8_0` modifie légèrement les sorties, `q4_0` perte mesurable — jamais posé par Loki |
|
||||
| `BATCH` / `UBATCH` | 2048 / 512 | lots du prefill | MoE aux experts en RAM : `UBATCH` 2048+ |
|
||||
| `LOKI_PERF_LOG` (environnement) | absent | une ligne `[perf]` par complétion sur stderr | — |
|
||||
|
||||
Outils sans clé, qui ne font rien d'eux-mêmes :
|
||||
|
||||
| Outil | Ce qu'il fait |
|
||||
|---|---|
|
||||
| **Bench** (bouton, `loki bench`, `--full`) | mesure en tâche de fond, à profondeur réelle avec le mode complet (prefill à froid, tours qui reprennent le cache, decode) |
|
||||
| **Optimiseur** (`loki tune`, bouton « Optimiser… ») | cherche lots, threads, marges, placement (`--placement`) et options opt-in (`--opt-in`) sur un moteur d'essai ; rien n'est écrit sans un clic |
|
||||
| **Dupliquer en placement auto…** (éditeur, MoE) | copie du preset où `--fit` place les experts au lieu de `-ot` / `--n-cpu-moe` |
|
||||
| **Version recommandée** (panneau Moteur) | met à jour llama.cpp vers un build ≥ `b10864`, sur clic, avec retour à la version précédente |
|
||||
|
||||
### Ordre de test conseillé
|
||||
|
||||
Une étape à la fois, mesurée avant la suivante :
|
||||
|
||||
1. **Bench complet** du preset tel quel : la référence.
|
||||
2. **`PREWARM=on` et `COMPACT_CONTINUATION=on`**, puis une vraie session de
|
||||
travail ; `GET /api/perf/summary` dit, par nature de requête
|
||||
(`kinds.main`…), combien de fois et de jetons le cache a été perdu
|
||||
(`lost_events`, `lost_tokens`) et le prefill par tour.
|
||||
3. **Optimiseur** (« Optimiser… ») sur le preset en service : lots, threads,
|
||||
marges.
|
||||
4. **MoE aux experts en RAM** : « Dupliquer en placement auto… », bascule sur
|
||||
la copie, `successfully fit params` au journal, bench complet des deux.
|
||||
5. **Mise à jour du moteur** vers la version recommandée si l'encart s'affiche
|
||||
(points de reprise des hybrides, MTP rapide) ; vérifier le rendu du gabarit
|
||||
dans le panneau.
|
||||
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP : bench, puis
|
||||
`draft_rate` dans `/api/perf/summary`.
|
||||
|
||||
### Ce qui a été écarté, et pourquoi
|
||||
|
||||
- **`--cache-reuse`** : réutilise des morceaux de cache calculés sous un autre
|
||||
contexte, donc change les sorties ; llama.cpp le coupe d'ailleurs pour les
|
||||
hybrides et le multimodal. Signalé s'il est posé à la main.
|
||||
- **`--context-shift`** à la place de la compaction : retire des jetons du
|
||||
contexte (perte), et ne marche pas sur les hybrides. Signalé lui aussi.
|
||||
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
|
||||
mesurablement, pour un gain d'environ 2 Gio sur un 27B hybride (16 couches
|
||||
sur 64 portent du KV). Reste un choix explicite et étiqueté (`KV_TYPE`).
|
||||
- Aussi : `-sm tensor` par défaut (prefill plus lent, expérimental), plus de
|
||||
slots par défaut (OOM : états récurrents par slot), `--cache-ram -1` (RAM
|
||||
non bornée), et tout réglage d'échantillonnage ou de quantification « pour
|
||||
aller plus vite ».
|
||||
|
||||
### Détail des clés et des outils
|
||||
|
||||
- **Bench** (bouton du preset, `loki bench`, `--full` pour le mode complet) :
|
||||
en tâche de fond (`POST /api/bench`, progression et annulation), chat,
|
||||
tâches et compaction refusés le temps de la mesure. Le mode rapide mesure une
|
||||
ligne courte avec le gabarit, le raisonnement et l'échantillonnage du preset ;
|
||||
le mode complet ajoute un prefill à froid à profondeur réelle (jusqu'à 32 k
|
||||
jetons, 16 k si des poids tournent sur CPU) et trois tours qui reprennent le
|
||||
cache — reprise lue dans `cache_n`, jamais supposée. Rien n'est enregistré
|
||||
sans réponses valides et timings réels ; l'empreinte du preset accompagne
|
||||
chaque mesure.
|
||||
- **Raisonnement renvoyé au modèle** (clé `REASONING_ECHO`, **off** par défaut,
|
||||
`loki config set REASONING_ECHO on`) : le raisonnement que le moteur local a
|
||||
séparé (`reasoning_content`) est gardé avec chaque message et renvoyé au même
|
||||
@@ -379,12 +756,19 @@ Ajoutées par ce fork :
|
||||
tracker, et le texte **complet** d'une description ou d'un `AGENTS.md` modifié
|
||||
— rien n'est perdu, seul ce petit bloc est à calculer. Une ligne du prompt
|
||||
système (présente seulement avec la clé) dit au modèle que ces blocs viennent
|
||||
de Loki et que le plus récent l'emporte. Le bloc est repris tout neuf, et les
|
||||
de Loki et que le plus récent l'emporte. La date du jour et le dossier de
|
||||
travail de la discussion (ou, pour un poste distant ciblé, son nom, son
|
||||
dossier et s'il est hors ligne) quittent aussi le prompt système pour ce
|
||||
bloc, les consignes restant dans le système : système et outils deviennent
|
||||
les mêmes d'une discussion à l'autre, et passer minuit n'ajoute qu'une ligne
|
||||
de mise à jour au lieu de tout recalculer. Le bloc est repris tout neuf, et les
|
||||
anciens `<context_update>` retirés, quand le début du prompt change de toute
|
||||
façon : compaction, système ou outils modifiés (date, réglages), redémarrage de
|
||||
façon : compaction, système ou outils modifiés (réglages, mode), redémarrage de
|
||||
Loki, changement de modèle, de preset, de projet, de mode mémoire ou de mode
|
||||
Code — et dès que les mises à jour accumulées deviennent trop longues. Les
|
||||
tâches planifiées gardent le bloc à jour à chaque tour ; sans agent, rien du
|
||||
tâches planifiées gardent le bloc à jour à chaque tour et, comme les
|
||||
sous-agents, la vérification et le terminal, leur système d'avant, date et
|
||||
dossier compris ; sans agent, rien du
|
||||
projet n'est envoyé ; un preset externe (API) n'est jamais concerné. Le titre, l'export JSON, le résumé de compaction et la
|
||||
passe de vérification ne voient pas ces blocs.
|
||||
- **Préchauffage du prochain tour** (clé `PREWARM`, **off** par défaut,
|
||||
@@ -466,8 +850,10 @@ Ajoutées par ce fork :
|
||||
et tout le tour est rendu autrement), ce rappel fait recalculer toute la
|
||||
boucle d'outils du tour. Avec `on`, le rappel part au bout du dernier résultat
|
||||
d'outil, et seul ce bout est à calculer — sur le moteur local, et seulement
|
||||
si la sonde de gabarit a conclu que le rendu de ce modèle bouge (« inconnu » :
|
||||
message à part, comme sans la clé). Le texte du rappel ne change pas, sa
|
||||
si la sonde de gabarit a conclu que le rendu de ce modèle bouge pour la forme
|
||||
de cette requête — mêmes outils, `chat_template_kwargs` et niveau de
|
||||
raisonnement (« inconnu » ou forme jamais sondée : message à part, comme
|
||||
sans la clé). Le texte du rappel ne change pas, sa
|
||||
place si : un modèle entraîné à se méfier des consignes lues dans une sortie
|
||||
d'outil peut moins bien le suivre. À comparer (tours qui concluent après le
|
||||
rappel) avant de l'adopter.
|
||||
@@ -639,8 +1025,16 @@ Ajoutées par ce fork :
|
||||
de 80 % (llama.cpp #26110 : swap, décodage de 25 à 7,5 t/s ; mlock : processus
|
||||
tué) ; **refus** au lancement seulement quand la borne basse dépasse 90 %
|
||||
(échec certain), avec la raison en clair, aussi affichée par l'interface.
|
||||
VRAM inconnue (Mac Intel, AMD, Vulkan, `--device`) ou serveurs `--rpc` :
|
||||
avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ;
|
||||
Le refus sort avec le code 78, que l'unité systemd écrite par
|
||||
`loki install` ne relance pas (`RestartPreventExitStatus=78`) ; une unité
|
||||
d'une version précédente (ou launchd) reçoit une sortie sans erreur pour ne
|
||||
pas boucler — `sudo systemctl edit loki-engine` (section `[Service]`,
|
||||
`RestartPreventExitStatus=78`) met l'unité à jour.
|
||||
La VRAM qui fonde un refus est celle des cartes que le moteur liste
|
||||
lui-même (`--list-devices`, lu seulement quand un refus se profile sur la
|
||||
VRAM NVIDIA) : un moteur Vulkan sur cartes mixtes NVIDIA + AMD compte les
|
||||
deux. Liste illisible, VRAM inconnue (Mac Intel, AMD seule, `--device`) ou
|
||||
serveurs `--rpc` : avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ;
|
||||
`LLAMA_ARG_NO_MMAP`/`LLAMA_ARG_MLOCK` ne comptent que sur un moteur ancien
|
||||
(un moteur à `--load-mode` les ignore). Rien ne change dans la ligne de
|
||||
commande.
|
||||
@@ -664,11 +1058,17 @@ Ajoutées par ce fork :
|
||||
claire ; les tâches planifiées attendent. Un verrou dont le propriétaire est
|
||||
mort (Loki tué en plein essai) est écarté avant tout démarrage du moteur :
|
||||
l'essai orphelin n'est arrêté que si PID, instant de démarrage ET binaire
|
||||
concordent (`taskkill /T` sous Windows), et le moteur est relancé au
|
||||
démarrage de l'interface s'il tournait avant. **Refus d'entrée** : preset
|
||||
concordent (`taskkill /T` sous Windows), et le moteur est relancé s'il
|
||||
tournait avant — au démarrage de l'interface, puis toutes les 30 s par le
|
||||
processus web (un `stat` tant qu'aucun verrou n'existe) : un `loki tune` tué
|
||||
par `kill -9` ne laisse plus le moteur arrêté. **Refus d'entrée** : preset
|
||||
externe, aucun preset actif, génération, tâche, bench ou job `bash_bg` en
|
||||
cours, moteur occupé (`/slots`) — en ligne de commande, seul ce dernier
|
||||
contrôle voit le processus web : préférer le bouton. **Essais** (descente étape par étape depuis
|
||||
contrôle voit le processus web : préférer le bouton. En ligne de commande
|
||||
aussi, un moteur qui ne serait pas celui de ce `LOKI_HOME` (il répond alors
|
||||
que ce dossier le dit arrêté, refuse sa clé d'API ou sert un autre `MODEL` :
|
||||
typiquement `sudo loki tune`, qui perd un `LOKI_HOME` exporté) fait
|
||||
refuser, avec la marche à suivre. **Essais** (descente étape par étape depuis
|
||||
la meilleure configuration du moment, chaque axe seulement si l'aide du
|
||||
moteur et la machine le permettent) : placement — seulement avec « inclure
|
||||
le placement » / `--placement` : `--fit` à la place des experts placés à la
|
||||
@@ -724,110 +1124,6 @@ Ajoutées par ce fork :
|
||||
decode) — sinon l'ancienne version est rétablie et le moteur relancé. Pendant
|
||||
la mesure, le chat est indisponible : prévoir 10 à 40 minutes selon la taille
|
||||
du modèle (un modèle relu depuis un disque lent recharge à chaque essai).
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les
|
||||
chemins relatifs du modèle y sont résolus. Changer de discussion change donc
|
||||
les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que
|
||||
le disque ne se remplisse pas en silence.
|
||||
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
|
||||
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
|
||||
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
|
||||
(Chromium via Playwright, inclus dans l'image). Les captures partent en JPEG
|
||||
et sont plafonnées à 20 fichiers / 40 Mo par discussion. La description de
|
||||
l'outil suit la capacité **réelle** du moteur, sondée sur `/props` : sans
|
||||
vision effective, elle dit au modèle « tu ne vois pas l'image » plutôt que de
|
||||
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
|
||||
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
|
||||
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
|
||||
- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
|
||||
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
|
||||
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
|
||||
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
|
||||
supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
|
||||
sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le
|
||||
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
|
||||
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
|
||||
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
|
||||
- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
|
||||
et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
|
||||
aucune requête vers un service de polices. Deux variantes : claire par défaut,
|
||||
**Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
|
||||
L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
|
||||
changement de preset ne demande plus d'ouvrir les réglages) ; la barre
|
||||
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
|
||||
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
|
||||
visibles en pied de colonne.
|
||||
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
|
||||
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
|
||||
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
|
||||
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
|
||||
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
|
||||
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
|
||||
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
|
||||
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
|
||||
du moteur. Conséquence directe : l'API est joignable partout où l'interface
|
||||
l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de
|
||||
second port ni ouvrir le moteur. L'amont annonçait `http://<ip>:8080/v1`, une
|
||||
adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP
|
||||
détectée est celle du bridge Docker).
|
||||
- Authentification par la **clé API** du panneau (`Authorization: Bearer …`),
|
||||
vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et
|
||||
l'interface le dit en rouge.
|
||||
- **Adresse publique** : un champ où saisir son domaine, pour le cas du
|
||||
reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse
|
||||
affichée suit celle du navigateur.
|
||||
- **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki
|
||||
honore `X-Forwarded-Proto` pour annoncer une adresse en `https`.
|
||||
- L'ancienne exposition publique via le relais de l'amont
|
||||
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
|
||||
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
|
||||
pouvait donc qu'échouer.
|
||||
- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une
|
||||
recherche légitime est pire que la laisser durer — mais au-delà de 24 appels
|
||||
sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui
|
||||
demande de conclure. Le rappel revient tous les 24 appels, en durcissant le
|
||||
ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière.
|
||||
Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf
|
||||
le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois
|
||||
les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` —
|
||||
`AGENT_BUDGET=off` le désactive complètement.
|
||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||
dans le fil.
|
||||
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
|
||||
volets — la nav des sections à gauche (IA, moteur, application), le panneau
|
||||
choisi à droite. La barre latérale ne garde que les discussions (les plus
|
||||
récentes en tête) et le moniteur machine.
|
||||
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
|
||||
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
|
||||
survit au rechargement, et un vieux tour garde le modèle de l'époque.
|
||||
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
|
||||
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
|
||||
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
|
||||
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
|
||||
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
|
||||
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
|
||||
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
|
||||
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
|
||||
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
|
||||
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
|
||||
l'affichage ne se fige plus.
|
||||
|
||||
Retirées par ce fork :
|
||||
|
||||
- **Accès distant via [ajean.link](https://ajean.link)** : la section de
|
||||
l'interface et son module JS sont supprimés — un conteneur derrière son
|
||||
propre réseau n'en a pas l'usage. Le code serveur du relais reste en place
|
||||
mais **inerte** (aucun jeton, aucune section pour en fournir un) : le retirer
|
||||
créerait un conflit à chaque reprise de l'amont.
|
||||
- **Postes distants** (faire agir l'agent sur un autre PC appairé) : bouton du
|
||||
composeur, modales d'appairage et module JS supprimés. Même traitement que
|
||||
ci-dessus — les routes `/api/node/*` subsistent mais plus rien ne peut
|
||||
générer de code d'appairage, donc aucun poste ne peut se connecter.
|
||||
- **Catalogue de modèles distant** : il interrogeait `ajean.link/models.json`,
|
||||
sa route n'avait aucun consommateur et son repli embarqué datait de 2024. La
|
||||
recherche Hugging Face le remplace.
|
||||
|
||||
## Différences avec l'amont
|
||||
|
||||
@@ -843,7 +1139,7 @@ Retirées par ce fork :
|
||||
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
|
||||
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
|
||||
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | au redémarrage du conteneur (release `rolling`) ; `docker compose pull` si l'image change |
|
||||
|
||||
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
|
||||
Pour récupérer les évolutions de l'amont :
|
||||
@@ -904,6 +1200,40 @@ courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intac
|
||||
Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
|
||||
solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
|
||||
|
||||
**Version recommandée.** Quand le moteur officiel qui tourne est antérieur à
|
||||
`b10864`, le panneau affiche un encart qui dit ce que la mise à jour apporte :
|
||||
les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next) ne sont plus
|
||||
évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise —, le MTP
|
||||
rapide (graphes CUDA) arrive à `b11009` et le MTP de qwen4exp à `b11331`. Le
|
||||
décodage spéculatif reste coupé tant que `SPEC` n'est pas posé. L'encart se
|
||||
décide sans réseau, sur le build du moteur (jamais pour un llama.cpp compilé ou
|
||||
un fork, dont le numéro ne prouve rien). Le bouton **Mettre à jour vers la
|
||||
version recommandée** n'interroge le registre qu'au clic : il prend le dernier
|
||||
build publié, vérifie qu'il atteint `b10864` et que son tag figé
|
||||
(`server-cuda-bNNNNN`) existe bien, puis demande confirmation. Sans réseau, il
|
||||
le dit et n'installe rien. Rien n'est jamais mis à jour d'office, ni au
|
||||
démarrage.
|
||||
|
||||
**Revenir à la version précédente.** Après une mise à jour, la version
|
||||
téléchargée qui tournait juste avant est gardée (les plus anciennes partent,
|
||||
~450 Mo chacune) ; le bouton **Revenir à la version précédente** y ramène sans
|
||||
réseau, et la version quittée devient à son tour « la précédente ».
|
||||
|
||||
**Le rendu du raisonnement.** Depuis `b10763`, llama-server garde par défaut la
|
||||
réflexion des tours passés dans le gabarit (`preserve_reasoning`). Un gabarit
|
||||
qui la retirait de lui-même (Qwen3.6, gabarits à `clear_thinking`) la rend alors
|
||||
— vide, puisque Loki ne la renvoie pas sans `REASONING_ECHO` : le modèle
|
||||
relirait un historique où il n'a jamais réfléchi. Avant de basculer vers un tel
|
||||
moteur, Loki lit le gabarit chargé et le verdict de la sonde de gabarit ; si le
|
||||
prompt rendu va changer et que `REASONING_PRESERVE` n'est pas posé, la
|
||||
confirmation l'explique et propose de poser `REASONING_PRESERVE=off` dans le
|
||||
preset actif (case cochée seulement quand le changement est établi :
|
||||
sur un gabarit qui garde déjà la réflexion, comme Qwen3.8, `off` changerait à
|
||||
son tour le rendu). L'utilisateur choisit. Après la bascule, le rendu de
|
||||
quelques conversations synthétiques est comparé, via `/apply-template`, entre
|
||||
l'ancien et le nouveau moteur une fois le modèle chargé : le panneau dit s'il
|
||||
est identique, ou montre le premier écart.
|
||||
|
||||
Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
|
||||
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
|
||||
|
||||
|
||||
+128
-72
@@ -1,93 +1,149 @@
|
||||
# Loki 0.14.0
|
||||
# Loki 0.15.0
|
||||
|
||||
Loki rattrape AJEAN jusqu'à la 0.17.6 et reprend une série d'idées d'OpenFox
|
||||
pour le mode Code. Le gros du travail est invisible : des discussions qui ne se
|
||||
bloquent plus sur un contexte plein, des réponses qui survivent à une coupure
|
||||
réseau, un agent de code qui gaspille moins de tours.
|
||||
Une version entièrement consacrée à la vitesse, avec une règle tenue de bout en
|
||||
bout : **ne jamais dénaturer le modèle**. Rien ne touche à la quantification
|
||||
des poids, à l'échantillonnage, à la température, au budget de raisonnement ni
|
||||
au contenu du contexte. Ce qui est sans perte est actif d'office ; tout ce qui
|
||||
changerait ce que voit le modèle, le placement des poids ou les slots du moteur
|
||||
attend une clé. Sans clé, la requête et la ligne de commande restent celles
|
||||
d'avant.
|
||||
|
||||
## Le contexte ne bloque plus la discussion
|
||||
La section **Performance** du README rassemble toutes les clés, leur défaut et
|
||||
leur prix.
|
||||
|
||||
Sur une fenêtre de 65k, un tour qui lisait plusieurs gros fichiers d'un coup
|
||||
passait de 60 % à plus de 130 % sans jamais compacter, et le moteur répondait
|
||||
par un 400 qui figeait la discussion.
|
||||
## Actif d'office, sans perte
|
||||
|
||||
- Le compactage en cours de tour compte aussi les résultats d'outils que le
|
||||
moteur n'a pas encore vus.
|
||||
- Tout résultat d'outil est borné à 30 000 caractères **pour le modèle** ;
|
||||
l'interface garde le résultat complet (« voir plus »).
|
||||
- En dernier recours, les gros résultats sont tronqués puis les plus vieux
|
||||
échanges retirés, au lieu de laisser remonter l'erreur.
|
||||
- Le compactage ne fait plus répondre deux fois à une vieille question, et le
|
||||
texte écrit avant un appel d'outil n'est plus rangé en double.
|
||||
**La conversation n'est plus recalculée pour rien.**
|
||||
|
||||
## Presets : API compatible OpenAI
|
||||
- **Cache de prompts dimensionné** : le moteur garde en RAM une copie exacte des
|
||||
conversations qu'il quitte. Son défaut ne tenait pas une conversation de 30 à
|
||||
65 k jetons à côté d'un vérificateur ou d'un sous-agent : il l'évinçait, et
|
||||
tout était recalculé (30 à 80 s sur un 27B). Il est maintenant agrandi pour un
|
||||
modèle tout-GPU, et le slot est effacé après chaque travail annexe pour que la
|
||||
conversation revienne du cache.
|
||||
- **Préfixe stable** : les rappels de Loki entrent dans l'historique tels
|
||||
qu'envoyés, la relance après un 500 ne change plus les outils, la ligne MCP ne
|
||||
manque plus au premier tour après un démarrage, l'ordre des trackers ne bouge
|
||||
plus. Chacun de ces écarts faisait recalculer toute la boucle d'outils.
|
||||
- **Compaction moins tôt** : le raisonnement que Loki ne renvoie jamais ne
|
||||
compte plus dans le contexte. La compaction (qui perd de l'information)
|
||||
partait vers 44-47 k au lieu de 49 k sur une fenêtre de 65 k. Son résumé n'est
|
||||
plus amputé de l'état d'avancement.
|
||||
- **Hybrides (Qwen3.5/3.6) sur un moteur ancien** : `--checkpoint-min-step 2048`
|
||||
d'office quand la RAM le permet, pour ne plus retomber jusqu'à ~8 k jetons en
|
||||
arrière à chaque reprise.
|
||||
|
||||
Les presets d'API externe gagnent ce qui leur manquait :
|
||||
**Le matériel est mieux employé.**
|
||||
|
||||
- une case **« le modèle accepte les images »** pour un modèle distant
|
||||
multimodal ;
|
||||
- plus de `chat_template_kwargs` (propre à llama.cpp) envoyé à l'API : une API
|
||||
stricte répondait 400 et **chaque compactage échouait** ;
|
||||
- **reprise automatique** quand le flux se coupe en pleine réponse (Wi-Fi, VPN,
|
||||
proxy) : jusqu'à 5 fois, le modèle reprend là où il s'était arrêté ;
|
||||
- un 401, 429 ou 502 n'est plus pris pour un appel d'outil mal formé ;
|
||||
- le débit est mesuré même quand l'API ne le donne pas ; le benchmark refuse de
|
||||
mesurer un moteur local qui n'est pas celui du preset ; le badge des réponses
|
||||
porte le nom du modèle distant.
|
||||
- **Threads CPU** : « auto » veut dire les cœurs physiques, plus tous les
|
||||
threads logiques — c'est le décodage des experts MoE sur CPU qui payait.
|
||||
- **Deux GPU** : file de lancements CUDA élargie quand le pipeline entre cartes
|
||||
est possible (même calcul ; seul le prompt peut gagner).
|
||||
|
||||
## Mode Code
|
||||
**Loki lui-même pèse moins.**
|
||||
|
||||
- **La vérification attend que le builder ait fini.** Il marque chaque critère
|
||||
`completed` une fois fait ; tant qu'il en reste d'ouverts, il est relancé au
|
||||
lieu de payer une passe de vérification sur un travail inachevé. Plus aucune
|
||||
vérification ne part quand il vient de poser une question.
|
||||
- **Écriture refusée dès son chemin** : un `write` sur un fichier jamais lu
|
||||
était refusé après tout le fichier généré. Le chemin passe maintenant en
|
||||
premier et le refus coupe la génération aussitôt.
|
||||
- **Appel d'outil écrit en texte** (`<tool_call>`, format XML de Qwen3-Coder) :
|
||||
repéré pendant le flux, coupé, et la relance cite l'extrait fautif.
|
||||
- `read_file`, `str_replace`, `old_string`… appris d'autres agents sont
|
||||
traduits vers les vrais outils.
|
||||
- Les consignes du dépôt (**AGENTS.md**, CLAUDE.md) font partie du contexte.
|
||||
- Terminal : `cmd | tail -N` rend le **vrai** code de sortie, la sortie déjà
|
||||
produite est gardée au délai dépassé, les couleurs ANSI sont retirées.
|
||||
- `git_status` / `git_diff` trouvent le dépôt cloné dans un sous-dossier.
|
||||
- Le compactage garde les fichiers touchés, les erreurs résolues et l'état des
|
||||
critères.
|
||||
- Écrire un gros fichier ne coûte plus un temps quadratique côté Go (jusqu'à
|
||||
8 s de CPU volées au décodage sur un MoE).
|
||||
- L'enregistrement de la discussion ne retarde plus le premier jeton.
|
||||
- Un seul `nvidia-smi` pour tous les onglets, aucun pour un onglet caché, un
|
||||
seul à l'ouverture de l'éditeur de preset.
|
||||
- Unraid : un `/data` ou `/models` servi par la couche FUSE est signalé, avec
|
||||
le remède.
|
||||
|
||||
## Tâches et chat
|
||||
**On peut enfin mesurer.**
|
||||
|
||||
- **« Rappelle-moi dans 20 minutes »** : `task_create` accepte `in_minutes` ou
|
||||
`at`, à l'heure du navigateur (le conteneur tourne en UTC).
|
||||
- **Écrire pendant que l'IA répond** : le message part en file et le modèle en
|
||||
tient compte dans la suite de sa réponse ; deux appareils qui envoient en
|
||||
même temps ne se refusent plus.
|
||||
- Longues discussions : au chargement, les 20 derniers échanges, le début d'un
|
||||
clic ; la liste des discussions se dessine par pages et montre la nouvelle
|
||||
dès le premier message.
|
||||
- Mémoire : un mode par projet, et un quatrième, « recherche ».
|
||||
- Appels d'outils parallèles séparés selon leur index ; bascule de preset par
|
||||
identifiant ; un raisonnement qui reprend après la réponse a sa propre bulle.
|
||||
- Chaque réponse dit ce qu'elle a repris du cache, recalculé et accepté du
|
||||
brouillon ; `GET /api/perf/summary` en fait la synthèse par nature de requête
|
||||
(`lost_events`, `lost_tokens` : le cache perdu, et après quoi).
|
||||
- **Bench** honnête, en tâche de fond : mode complet à profondeur réelle, tours
|
||||
qui reprennent le cache, rien d'enregistré sans timings réels.
|
||||
- Une sonde du gabarit de chat dit, sans rien envoyer au modèle, si le rendu
|
||||
reste stable d'un message à l'autre.
|
||||
|
||||
## Sécurité et données
|
||||
**Garde-fous.** Un cache KV quantifié, `--context-shift` ou `--cache-reuse`
|
||||
posés à la main sont signalés. Un mode de chargement résident voué à l'échec
|
||||
(poids restés en RAM au-delà de 90 %) est refusé avec la raison en clair —
|
||||
`LOAD_GUARD=off` pour passer outre ; le refus n'est plus relancé en boucle par
|
||||
systemd, et un moteur Vulkan sur cartes mixtes n'est plus refusé à tort.
|
||||
|
||||
- **Un site tiers ne peut plus piloter Loki** depuis le navigateur (Origin /
|
||||
Sec-Fetch-Site, DNS rebinding). ⚠️ Derrière un reverse proxy par nom de
|
||||
domaine, définis une clé de pilotage ou `LOKI_TRUSTED_HOSTS` **avant** la mise
|
||||
à jour, et vérifie que le proxy transmet l'en-tête `Host` d'origine.
|
||||
- **Chiffrement de la mémoire** : l'activer rendait illisibles la discussion
|
||||
active et le mode Code. Corrigé ; une base déjà touchée est réparée au
|
||||
déverrouillage. Les résultats d'outils (« voir plus ») et les images du fil
|
||||
sont désormais chiffrés eux aussi, et les images effacées après 24 h.
|
||||
## En opt-in : à essayer, à mesurer
|
||||
|
||||
## Moteur
|
||||
| Clé | Ce qu'elle fait |
|
||||
|---|---|
|
||||
| `PREWARM=on` | prépare le prochain tour pendant que tu lis (`full` : aussi la discussion qu'on ouvre) |
|
||||
| `COMPACT_CONTINUATION=on` | le résumé de compaction prolonge le prompt en cache au lieu d'être calculé à froid |
|
||||
| `PROJ_SNAPSHOT=on` | bloc projet figé par discussion, changements livrés à part ; date et dossier sortent du système |
|
||||
| `REASONING_ECHO=on` | renvoie au moteur local la réflexion du modèle, au format entraîné |
|
||||
| `SPEC=auto` / `mtp` | décodage spéculatif MTP (tête détectée dans le fichier, garde-fous VRAM) |
|
||||
| `SPEC=ngram` / `mtp+ngram` | n-grammes du contexte, utiles en mode Code |
|
||||
| `SIDE_SLOT=on` | second slot pour les travaux annexes (VRAM en double) |
|
||||
| `SLOT_PERSIST=on` | état du slot gardé à la bascule de preset |
|
||||
| `SPLIT_MODE=tensor` | parallélisme de tenseurs entre cartes (expérimental) |
|
||||
| `FIT_TARGET`, `OP_OFFLOAD_MIN_BATCH`, `CUDA_GRAPH_OPT` | réglages d'expert pour cartes inégales et MoE |
|
||||
| `KEEP_TURN_IMAGES=on` | **zone grise** : garde les images des outils dans l'historique |
|
||||
| `NUDGE_IN_TOOL=on` | **zone grise** : rappel de budget dans le résultat d'outil |
|
||||
|
||||
- `--mlock` seul ne coupe plus le mmap (OOM au chargement).
|
||||
- Port déjà occupé refusé avec un message clair ; cache KV lent signalé.
|
||||
- Réponses web compressées en gzip.
|
||||
Et deux outils qui ne font rien d'eux-mêmes :
|
||||
|
||||
- **Optimiseur** (`loki tune`, bouton « Optimiser… ») : cherche lots, threads,
|
||||
marges et placement sur un moteur d'essai isolé ; rien n'est écrit sans un
|
||||
clic, et l'application est vérifiée puis défaite si le moteur ne tient pas.
|
||||
- **« Dupliquer en placement auto… »** pour un MoE aux experts placés à la
|
||||
main : une copie du preset où `--fit` les répartit.
|
||||
|
||||
## Ordre de test conseillé
|
||||
|
||||
Une étape à la fois, mesurée avant la suivante :
|
||||
|
||||
1. **Bench complet** du preset tel quel : la référence.
|
||||
2. **`PREWARM` + `COMPACT_CONTINUATION`**, une vraie session de travail, puis
|
||||
`/api/perf/summary` : le cache perdu (`lost`) doit baisser.
|
||||
3. **Optimiseur** sur le preset en service.
|
||||
4. **MoE** : « Dupliquer en placement auto… », bench complet des deux.
|
||||
5. **Mise à jour du moteur** vers la version recommandée (encart du panneau
|
||||
Moteur).
|
||||
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP.
|
||||
|
||||
## Moteur : version recommandée et retour arrière
|
||||
|
||||
Quand le moteur officiel est antérieur à `b10864`, le panneau Moteur dit ce
|
||||
qu'une mise à jour apporte (points de reprise des hybrides, MTP rapide) et la
|
||||
propose — sur clic seulement, version vérifiée sur le registre. La version
|
||||
quittée est gardée : **Revenir à la version précédente** y ramène sans réseau.
|
||||
|
||||
Depuis `b10763`, llama-server garde par défaut la réflexion des tours passés :
|
||||
un gabarit comme Qwen3.6 la rendrait vide. Avant de basculer, Loki le détecte et
|
||||
propose `REASONING_PRESERVE=off` ; après, il compare le rendu de l'ancien et du
|
||||
nouveau moteur et signale le premier écart.
|
||||
|
||||
## Ce qui a été écarté
|
||||
|
||||
- **`--cache-reuse`** : réutilise du cache calculé sous un autre contexte, donc
|
||||
change les sorties.
|
||||
- **`--context-shift`** : retire des jetons du contexte, et ne marche pas sur
|
||||
les hybrides.
|
||||
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
|
||||
mesurablement, pour ~2 Gio gagnés sur un 27B hybride. Reste un choix
|
||||
explicite (`KV_TYPE`).
|
||||
|
||||
## Autres corrections
|
||||
|
||||
- Une compaction en plein tour ne fait plus disparaître le rappel des pages
|
||||
mémoire lues.
|
||||
- Un `loki tune` tué net ne laisse plus le moteur arrêté ; lancé avec un autre
|
||||
`LOKI_HOME` que l'interface (`sudo`), il refuse au lieu de charger un essai à
|
||||
côté d'un moteur vivant.
|
||||
- L'ancienne option « n-grammes (mod) » de l'éditeur rejoint `SPEC=ngram`, avec
|
||||
une migration proposée (jamais faite d'office).
|
||||
- Accès refusé : l'interface dit pourquoi et comment le lever.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
```
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Installation systemd (`loki install`) : l'unité du moteur gagne
|
||||
`RestartPreventExitStatus=78`. `loki update` ne réécrit pas les unités ;
|
||||
`sudo loki install`, ou `sudo systemctl edit loki-engine` avec cette ligne dans
|
||||
`[Service]`, la met à jour.
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,13 +2,13 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 14,
|
||||
"Minor": 15,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 14,
|
||||
"Minor": 15,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||
"OriginalFilename": "loki.exe",
|
||||
"ProductName": "Loki",
|
||||
"ProductVersion": "0.14.0",
|
||||
"ProductVersion": "0.15.0",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
|
||||
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
|
||||
#
|
||||
# Mise à jour : Compose Down puis Up avec « pull image », ou
|
||||
# « docker compose pull » avant Up.
|
||||
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
|
||||
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
|
||||
# Si le journal demande une nouvelle image : Compose Down puis Up avec
|
||||
# « pull image », ou « docker compose pull » avant Up.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
services:
|
||||
loki:
|
||||
@@ -39,6 +41,9 @@ services:
|
||||
# sinon l'API répond 403 (protection contre les sites tiers / DNS
|
||||
# rebinding). Inutile pour un accès par IP ou nom local.
|
||||
- LOKI_TRUSTED_HOSTS=
|
||||
# Conversation tenue par la façade Rust (défaut). « go » la rend au Go si
|
||||
# quelque chose ne va pas — puis Compose Down / Up.
|
||||
# - LOKI_CHAT=go
|
||||
volumes:
|
||||
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
|
||||
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
|
||||
@@ -64,4 +69,17 @@ services:
|
||||
- /mnt/user/appdata/loki/data:/data
|
||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||
- /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
|
||||
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||
ulimits:
|
||||
memlock: -1
|
||||
restart: unless-stopped
|
||||
@@ -37,4 +37,8 @@ services:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: ["gpu"]
|
||||
# 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.
|
||||
ulimits:
|
||||
memlock: -1
|
||||
restart: unless-stopped
|
||||
+39
-3
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
|
||||
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
|
||||
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
|
||||
# configuration au premier démarrage, lance le moteur (supervision PID, sans
|
||||
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
|
||||
set -eu
|
||||
|
||||
: "${LOKI_HOME:=/data}"
|
||||
@@ -15,6 +16,33 @@ if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
|
||||
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
|
||||
fi
|
||||
|
||||
# ── Mise à jour au démarrage ───────────────────────────────────────────────
|
||||
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
|
||||
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
|
||||
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
|
||||
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
|
||||
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
|
||||
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
|
||||
/usr/local/bin/loki-rs self-update || true
|
||||
|
||||
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
|
||||
# est plus récent que l'image et de même ABI, sinon l'image.
|
||||
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
|
||||
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
|
||||
BIN_DIR=/usr/local/bin
|
||||
fi
|
||||
echo "[entrypoint] binaires Loki : $BIN_DIR"
|
||||
# Tous les appels « loki » ci-dessous visent le binaire choisi.
|
||||
loki() { "$BIN_DIR/loki" "$@"; }
|
||||
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
|
||||
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
|
||||
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
|
||||
if [ "$BIN_DIR" != /usr/local/bin ]; then
|
||||
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
|
||||
else
|
||||
rm -f /usr/local/sbin/loki 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
|
||||
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
|
||||
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
|
||||
@@ -59,4 +87,12 @@ else
|
||||
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
|
||||
fi
|
||||
|
||||
exec loki web "${LOKI_WEB_PORT:-8090}"
|
||||
# Façade Rust (loki-rs) sur le port public ; elle lance `loki web` sur
|
||||
# 127.0.0.1:${LOKI_GO_PORT:-8091} et lui relaie tout. LOKI_FACADE=0 revient à
|
||||
# l'ancien schéma (Go seul, directement sur le port public) — porte de sortie
|
||||
# si la façade pose problème.
|
||||
export LOKI_WEB_PORT="${LOKI_WEB_PORT:-8090}"
|
||||
if [ "${LOKI_FACADE:-1}" = "0" ]; then
|
||||
exec "$BIN_DIR/loki" web "$LOKI_WEB_PORT"
|
||||
fi
|
||||
exec "$BIN_DIR/loki-rs" serve
|
||||
@@ -0,0 +1,122 @@
|
||||
# Migration Rust lot 1 — façade + mise à jour au démarrage — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Mettre un binaire Rust `loki-rs` en frontal du conteneur (proxy vers le Go) et faire que le conteneur prenne les derniers binaires publiés à chaque démarrage.
|
||||
|
||||
**Architecture:** `loki-rs serve` écoute sur `LOKI_WEB_PORT` (8090), lance `loki web 8091` (Go, `LOKI_WEB_BIND=127.0.0.1`) en enfant supervisé et lui proxifie tout (HTTP, SSE, WebSocket). `loki-rs self-update` télécharge la release GitHub `rolling` dans `/data/bin/<build>/`, `loki-rs which-bin` dit à l'entrypoint quel dossier de binaires utiliser.
|
||||
|
||||
**Tech Stack:** Rust 2021 (tokio, hyper 1, hyper-util, http-body-util, reqwest rustls, sha2, serde_json), cible `x86_64-unknown-linux-musl` ; Go 1.25 existant ; GitHub Actions.
|
||||
|
||||
Spec : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Port public inchangé : 8090 (`LOKI_WEB_PORT`). Go interne : `127.0.0.1:8091` (`LOKI_GO_PORT`).
|
||||
- Release : tag `rolling`, pré-release, assets `loki-linux`, `loki-rs-linux`, `SHA256SUMS`, `manifest.json` (uploadé en dernier).
|
||||
- `manifest.json` = `{"build": <github.run_number>, "abi": <IMAGE_ABI>, "commit": "<sha>"}`.
|
||||
- Source unique de l'ABI : fichier `IMAGE_ABI` à la racine (contenu `1`), copié dans l'image en `/etc/loki/IMAGE_ABI`. Build de l'image : `ENV LOKI_BUILD=<run_number>` (0 en build local).
|
||||
- Les binaires de `/data/bin/<build>/` ne sont utilisés que si `abi == IMAGE_ABI` **et** `build > LOKI_BUILD`.
|
||||
- `LOKI_AUTO_UPDATE=0` : aucune requête réseau. Le boot n'est jamais bloqué (code 0, timeouts bornés).
|
||||
- La façade efface `X-Loki-Via` entrant ; transmet `Host`, `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` intacts ; retire les hop-by-hop ; ne bufferise rien.
|
||||
- Go : `gofmt -l .` vide + `staticcheck ./...` propre. Rust : `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
|
||||
- Git : jamais `git add -A` ni `git stash` ; commits en français, style narratif.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
IMAGE_ABI ABI d'image (entier)
|
||||
rust/Cargo.toml workspace
|
||||
rust/loki-rs/Cargo.toml
|
||||
rust/loki-rs/src/main.rs CLI : serve | self-update | which-bin | version
|
||||
rust/loki-rs/src/proxy.rs proxy HTTP/SSE/WS vers le Go
|
||||
rust/loki-rs/src/child.rs supervision de `loki web`
|
||||
rust/loki-rs/src/update/mod.rs orchestration self-update + which-bin
|
||||
rust/loki-rs/src/update/sums.rs parse SHA256SUMS
|
||||
rust/loki-rs/src/update/state.rs release.json, décision, sélection du dossier
|
||||
rust/loki-rs/tests/proxy.rs intégration façade
|
||||
rust/loki-rs/tests/update.rs intégration self-update (faux serveur)
|
||||
internal/loki/web_server.go LOKI_WEB_BIND
|
||||
Dockerfile étape rustbuild, IMAGE_ABI, LOKI_BUILD
|
||||
docker-entrypoint.sh self-update, which-bin, exec loki-rs serve
|
||||
.github/workflows/ci.yml job rust
|
||||
.github/workflows/docker-publish.yml LOKI_BUILD + job rolling
|
||||
README.md section mise à jour au démarrage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Go — adresse d'écoute configurable
|
||||
|
||||
**Files:** Modify `internal/loki/web_server.go` (cmdWeb), Test `internal/loki/web_bind_test.go`
|
||||
|
||||
**Interfaces:** Produces `webBindAddr(port int) string` — `LOKI_WEB_BIND` (défaut `0.0.0.0`) + `:port`.
|
||||
|
||||
- [ ] Test : `t.Setenv("LOKI_WEB_BIND","")` → `"0.0.0.0:8090"` ; `"127.0.0.1"` → `"127.0.0.1:8091"`.
|
||||
- [ ] `go test ./internal/loki -run TestWebBindAddr` → FAIL (indéfini).
|
||||
- [ ] Implémenter, l'utiliser dans `cmdWeb` à la place de `fmt.Sprintf("0.0.0.0:%d", port)`.
|
||||
- [ ] Test PASS, `gofmt -l .` vide, staticcheck propre. Commit.
|
||||
|
||||
### Task 2: Workspace Rust + parse SHA256SUMS + état d'installation
|
||||
|
||||
**Files:** Create `rust/Cargo.toml`, `rust/loki-rs/Cargo.toml`, `src/main.rs` (squelette CLI), `src/update/{mod,sums,state}.rs`, `.gitignore` (`rust/target/`).
|
||||
|
||||
**Interfaces (produits) :**
|
||||
- `sums::parse(text:&str) -> HashMap<String,String>` (nom → hex minuscule ; accepte `*nom`, ignore lignes vides/invalides).
|
||||
- `state::Manifest { build:u64, abi:u32, commit:String }` (serde).
|
||||
- `state::Installed` = Manifest écrit dans `<dir>/release.json`.
|
||||
- `state::read_installed(dir:&Path) -> Option<Manifest>`.
|
||||
- `state::select_dir(data_bin:&Path, image_dir:&Path, image_build:u64, image_abi:u32) -> PathBuf` : `<data_bin>/<build>` si release.json valide, abi égal, build > image_build, et `loki` + `loki-rs` présents dedans ; sinon `image_dir`.
|
||||
- `state::needs_update(m:&Manifest, image_build:u64, image_abi:u32, installed:Option<&Manifest>) -> Decision` avec `enum Decision { Install, UpToDate, AbiMismatch }`.
|
||||
|
||||
- [ ] Tests unitaires : parse (2 espaces, `*`, majuscules → minuscules, ligne cassée ignorée) ; `needs_update` (abi ≠ → AbiMismatch ; build ≤ image → UpToDate ; build ≤ installé → UpToDate ; sinon Install) ; `select_dir` (aucun fichier → image ; abi ≠ → image ; build ≤ image → image ; binaire manquant → image ; ok → data dir).
|
||||
- [ ] `cargo test` FAIL puis implémentation, PASS, fmt + clippy. Commit.
|
||||
|
||||
### Task 3: self-update (téléchargement vérifié, installation atomique)
|
||||
|
||||
**Files:** `src/update/mod.rs`, `tests/update.rs`
|
||||
|
||||
**Interfaces :** `update::run(cfg:&UpdateConfig) -> Outcome` (jamais d'erreur remontée au CLI : journalise et rend `Outcome::{Installed(u64), UpToDate, AbiMismatch, Failed(String), Disabled}`) ; `UpdateConfig { base_url, data_bin, image_build, image_abi, enabled, timeout }` construit par `UpdateConfig::from_env()` (`LOKI_UPDATE_URL` défaut `https://github.com/R0m1k3/Loki/releases/download/rolling`, `LOKI_HOME/bin`, `LOKI_BUILD`, `/etc/loki/IMAGE_ABI` puis `LOKI_IMAGE_ABI`, `LOKI_AUTO_UPDATE`).
|
||||
|
||||
Algorithme : manifeste → décision → `SHA256SUMS` → télécharge `loki-linux`, `loki-rs-linux` dans `<data_bin>/.tmp-<build>/` → vérifie chaque hash → chmod 755 (unix) → renomme en `loki`, `loki-rs` → écrit `release.json` → renomme le dossier en `<data_bin>/<build>` → écrit `<data_bin>/release.json` (tmp + rename) → supprime les autres `<n>/` et `.tmp-*`. Toute erreur : supprime `.tmp-<build>`, `Failed`.
|
||||
|
||||
- [ ] Tests d'intégration (faux serveur hyper sur 127.0.0.1:0 servant des fichiers en mémoire) : installation OK (fichiers présents, release.json, select_dir pointe dessus) ; hash faux → rien installé, ancien état intact ; abi ≠ → rien téléchargé hormis le manifeste ; serveur absent → Failed, pas de panic ; `enabled=false` → Disabled sans requête ; seconde exécution → UpToDate et nettoyage des anciens builds.
|
||||
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
|
||||
|
||||
### Task 4: Façade proxy
|
||||
|
||||
**Files:** `src/proxy.rs`, `tests/proxy.rs`
|
||||
|
||||
**Interfaces :** `proxy::serve(listener: tokio::net::TcpListener, backend: SocketAddr, shutdown: impl Future) -> io::Result<()>`.
|
||||
|
||||
Comportement : hyper http1 + `with_upgrades` ; chaque requête → client hyper-util vers `backend` (URI `http://backend{path_and_query}`), Host d'origine conservé ; retire hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`, + noms listés dans `Connection`) et `x-loki-via` ; si requête d'upgrade (`Connection: upgrade` + `Upgrade`), garde `Connection: upgrade` + `Upgrade`, attend 101, puis `copy_bidirectional` entre les deux côtés upgradés ; corps en streaming dans les deux sens ; erreur de connexion → 502 `text/plain` « Loki (Go) redémarre — réessaie dans un instant ».
|
||||
|
||||
- [ ] Tests (faux backend hyper) : GET relayé avec Host/Authorization/Origin/X-Forwarded-Proto intacts et `X-Loki-Via` absent côté backend ; POST corps relayé ; SSE : backend envoie 1er événement puis attend 500 ms — le client reçoit le 1er avant 300 ms ; WebSocket écho via tokio-tungstenite ; backend arrêté → 502.
|
||||
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
|
||||
|
||||
### Task 5: Supervision du Go + commande `serve`
|
||||
|
||||
**Files:** `src/child.rs`, `src/main.rs`
|
||||
|
||||
**Interfaces :** `child::Supervisor::spawn(go_bin:PathBuf, port:u16) -> Supervisor` (relance avec backoff 1 s → 30 s, remis à 1 s après 60 s de vie ; stdin null, stdout/stderr hérités ; env `LOKI_WEB_BIND=127.0.0.1`) ; `Supervisor::shutdown(self, grace:Duration)` (SIGTERM unix / kill windows, puis kill après `grace`).
|
||||
`main serve` : go_bin = `LOKI_GO_BIN` sinon `loki` à côté de l'exe courant ; attend jusqu'à 30 s que `127.0.0.1:LOKI_GO_PORT` accepte (sinon ouvre quand même, 502 en attendant) ; `proxy::serve` sur `0.0.0.0:LOKI_WEB_PORT` ; SIGTERM/SIGINT → arrêt du proxy + `shutdown(10 s)`.
|
||||
`main which-bin` imprime `select_dir(...)` ; `main version` imprime version crate + `LOKI_BUILD`.
|
||||
|
||||
- [ ] Test unitaire `go_bin` par défaut ; test du backoff (fonction pure `next_backoff(prev, lived) -> Duration`).
|
||||
- [ ] Test manuel local : `loki-rs serve` avec un `loki.exe` construit → `curl localhost:8090/` renvoie l'UI.
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 6: Image, entrypoint, CI, release rolling, doc
|
||||
|
||||
**Files:** `IMAGE_ABI`, `Dockerfile`, `docker-entrypoint.sh`, `.github/workflows/ci.yml`, `.github/workflows/docker-publish.yml`, `README.md`.
|
||||
|
||||
- Dockerfile : étape `FROM rust:1-alpine AS rustbuild` (`apk add musl-dev`, `cargo build --release --locked`), copie `/usr/local/bin/loki-rs` ; `ARG LOKI_BUILD=0` → `ENV LOKI_BUILD` ; `COPY IMAGE_ABI /etc/loki/IMAGE_ABI`.
|
||||
- Entrypoint : `loki-rs self-update` (best-effort) ; `BIN_DIR=$(loki-rs which-bin)` ; `LOKI="$BIN_DIR/loki"` pour tous les appels config/start ; `exec "$BIN_DIR/loki-rs" serve`.
|
||||
- CI : job `rust` (fmt, clippy, test sur ubuntu).
|
||||
- docker-publish : `LOKI_BUILD=${{ github.run_number }}` en build-arg ; job `rolling` (contents: write, concurrency `rolling`) : build Go linux CGO=0, build Rust musl (`rustup target add x86_64-unknown-linux-musl`, `musl-tools`), SHA256SUMS, manifest, `gh release create rolling --prerelease` si absent, upload `--clobber` binaires+sums puis manifest.
|
||||
- README : section « Mise à jour au démarrage » (comportement, `LOKI_AUTO_UPDATE=0`, ABI → pull).
|
||||
- [ ] `sh -n docker-entrypoint.sh`, YAML relu, commit.
|
||||
|
||||
## Self-review
|
||||
|
||||
Spec couvert : façade (T4/T5), X-Loki-Via (T4), bind Go (T1), rolling + manifeste + ABI (T2/T3/T6), repli et boot non bloquant (T3/T6), désactivation (T3), tests (T2–T4), CI (T6). Écart assumé vs spec : ordre des versions par `build` (run_number) et non par commit, et installation dans `/data/bin/<build>/` (bascule atomique par `release.json`).
|
||||
@@ -0,0 +1,150 @@
|
||||
# Migration de Loki vers Rust — lot 1 : façade Rust + mise à jour au démarrage
|
||||
|
||||
Date : 2026-10-13
|
||||
Statut : validé (design), à planifier
|
||||
|
||||
## Contexte
|
||||
|
||||
Loki est aujourd'hui un monobinaire Go (`internal/loki`, ~65 000 lignes hors
|
||||
tests) qui sert une UI embarquée (~26 000 lignes JS/CSS/HTML) et pilote
|
||||
llama.cpp, Strata, MCP, le mode Code, etc. L'objectif à terme est de le
|
||||
réécrire en Rust. Une réécriture d'un bloc ne livrerait rien pendant des
|
||||
semaines : la migration est **progressive**, par lots, avec Rust en production
|
||||
dès le premier lot.
|
||||
|
||||
Second besoin : aujourd'hui, un `docker restart` relance la même image. Le
|
||||
conteneur doit prendre la dernière version de Loki **au prochain démarrage**,
|
||||
sans `docker compose pull` manuel.
|
||||
|
||||
## Architecture de cohabitation (« strangler fig »)
|
||||
|
||||
```
|
||||
:8090 ──► loki-rs (Rust, axum/tokio)
|
||||
├─ routes portées → traitées en Rust
|
||||
└─ tout le reste → proxy HTTP / SSE / WebSocket
|
||||
→ loki web (Go) sur 127.0.0.1:8091
|
||||
```
|
||||
|
||||
- Nouveau workspace Cargo dans `rust/`, binaire `loki-rs`, compilé en statique
|
||||
(`x86_64-unknown-linux-musl`) : indépendant de la glibc de l'image.
|
||||
- Go écoute uniquement en local sur 8091 (nouvelle option d'adresse d'écoute
|
||||
de `loki web`). Le port public reste 8090 : compose et reverse proxy
|
||||
inchangés.
|
||||
- Au lot 1, **aucune route n'est portée** : la façade proxifie tout. Elle sert
|
||||
de socle aux lots suivants.
|
||||
- Authentification (clé de pilotage, `LOKI_TRUSTED_HOSTS`) : reste à Go tant
|
||||
qu'il sert la route. Go ne fonde aucune décision d'accès sur l'IP source
|
||||
(vérifié : ni `RemoteAddr` ni `X-Forwarded-For` dans `internal/loki`), donc
|
||||
passer par 127.0.0.1 n'élargit rien. La façade transmet `Host`,
|
||||
`Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`
|
||||
**sans les altérer**, et retire seulement les en-têtes hop-by-hop.
|
||||
- **Sécurité** : la façade **efface `X-Loki-Via`** de toute requête entrante.
|
||||
Cet en-tête, posé par le tunnel du relais, dispense du contrôle d'hôte
|
||||
anti-DNS-rebinding (`crossSiteReject`). Aujourd'hui l'écoute HTTP directe de
|
||||
Go ne l'efface pas : sans clé de pilotage, une page en DNS rebinding peut le
|
||||
forger. La façade ferme ce trou dans le conteneur ; un correctif Go séparé
|
||||
le ferme pour `loki web` hors conteneur. Test dédié.
|
||||
- Flux : pas de timeout d'écriture, pas de bufferisation des réponses
|
||||
`text/event-stream` (chat SSE, `/v1` en streaming), upgrade WebSocket relayé
|
||||
(`/api/node/ws` et autres). Pas de recompression : Go gère déjà gzip.
|
||||
- Le relais `loki link` reste entièrement côté Go jusqu'à son propre lot.
|
||||
|
||||
### Démarrage et arrêt
|
||||
|
||||
`loki-rs serve` lance `loki web 8091` (Go) comme processus enfant, attend
|
||||
qu'il réponde, puis ouvre 8090. Si l'enfant meurt, la façade le relance
|
||||
(backoff borné) et répond 502 avec un message clair entre-temps. Sur SIGTERM,
|
||||
la façade transmet le signal à l'enfant et attend sa fin (moteur llama-server
|
||||
arrêté proprement par Go, comme aujourd'hui).
|
||||
|
||||
## Mise à jour au démarrage
|
||||
|
||||
### Publication (CI)
|
||||
|
||||
À chaque push sur `main`, en plus de l'image GHCR, un workflow publie la
|
||||
release GitHub **`rolling`** (pré-release, assets écrasés) :
|
||||
|
||||
- `loki-linux` — binaire Go (déjà `CGO_ENABLED=0`)
|
||||
- `loki-rs-linux` — binaire Rust musl
|
||||
- `SHA256SUMS` — format `sha256sum` (celui que `loki update` sait déjà lire)
|
||||
- `manifest.json` — `{"commit": "<sha>", "image_abi": <entier>}`
|
||||
|
||||
La release `rolling` n'est jamais marquée `latest` : `loki update` (machines
|
||||
non conteneurisées) continue de suivre les tags `vX.Y.Z`.
|
||||
|
||||
### ABI d'image
|
||||
|
||||
Le Dockerfile déclare `ENV LOKI_IMAGE_ABI=1`. On l'incrémente (Dockerfile et
|
||||
source du manifeste dans le même commit) quand le code exige un changement de
|
||||
l'image : nouvelle dépendance système, Python/Node, chemin du moteur, etc.
|
||||
Un binaire publié avec une ABI différente de celle de l'image n'est **jamais**
|
||||
installé : il ne pourrait pas tourner correctement.
|
||||
|
||||
### Au boot (`loki-rs self-update`)
|
||||
|
||||
Appelé par `docker-entrypoint.sh`, avec le `loki-rs` de l'image :
|
||||
|
||||
1. `LOKI_AUTO_UPDATE=0` → ne fait rien.
|
||||
2. Télécharge `manifest.json` (timeout global 20 s). Erreur réseau → étape 6.
|
||||
3. `image_abi` ≠ `LOKI_IMAGE_ABI` → journal :
|
||||
« nouvelle version nécessitant une nouvelle image : docker compose pull »,
|
||||
étape 6.
|
||||
4. `commit` identique à `/data/bin/VERSION` → rien à faire, étape 6.
|
||||
5. Télécharge les deux binaires en `/data/bin/*.part`, vérifie chacun contre
|
||||
`SHA256SUMS`, `chmod +x`, renomme atomiquement, écrit `VERSION` en dernier.
|
||||
Toute erreur → `.part` supprimés, rien remplacé.
|
||||
6. Fin. Code de sortie 0 dans tous les cas : le boot n'est jamais bloqué.
|
||||
|
||||
L'entrypoint choisit ensuite les binaires : `/data/bin/loki` et
|
||||
`/data/bin/loki-rs` s'ils existent, sont exécutables **et** que
|
||||
`/data/bin/ABI` égale `LOKI_IMAGE_ABI` ; sinon ceux de l'image. Les deux
|
||||
binaires viennent toujours de la même source (jamais un Go de l'image avec un
|
||||
Rust de `/data/bin`). Le chemin Go choisi est passé à la façade
|
||||
(`LOKI_GO_BIN`) et utilisé pour les appels `loki config` de l'entrypoint.
|
||||
|
||||
Limite assumée : seuls les binaires Loki se mettent à jour ainsi ; llama.cpp
|
||||
se met à jour via l'UI (existant), le reste de l'image via un pull signalé par
|
||||
l'ABI.
|
||||
|
||||
## Organisation du code Rust
|
||||
|
||||
```
|
||||
rust/
|
||||
Cargo.toml workspace
|
||||
loki-rs/
|
||||
src/main.rs CLI : serve | self-update | version
|
||||
src/proxy.rs façade HTTP/SSE/WebSocket
|
||||
src/child.rs supervision du processus Go
|
||||
src/update/ manifeste, téléchargement, SHA256SUMS, installation
|
||||
```
|
||||
|
||||
Dépendances : `tokio`, `axum`, `hyper`/`hyper-util`, `reqwest` (rustls,
|
||||
sans OpenSSL — musl), `sha2`, `serde`/`serde_json`, `clap`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unitaires : parse de `SHA256SUMS` (entrées `*nom`, nom absent), décision
|
||||
d'installation (ABI différente, même commit, `LOKI_AUTO_UPDATE=0`),
|
||||
installation atomique (échec au milieu → rien remplacé).
|
||||
- Intégration : façade devant un faux backend axum —
|
||||
SSE reçu morceau par morceau (pas en bloc), WebSocket écho,
|
||||
en-têtes `Host`/`Authorization`/`Origin` transmis intacts, `X-Loki-Via`
|
||||
entrant effacé, 502 si backend absent.
|
||||
- Mise à jour : faux serveur de release local (URL de base injectable par
|
||||
variable d'environnement de test).
|
||||
- CI : `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` dans un
|
||||
job dédié ; la CI Go (gofmt + staticcheck + tests) reste inchangée.
|
||||
|
||||
## Feuille de route des lots suivants (hors périmètre de ce spec)
|
||||
|
||||
| Lot | Contenu |
|
||||
|---|---|
|
||||
| 2 | Fichiers statiques de l'UI ; lecture de la config via une API interne Go |
|
||||
| 3 | Supervision de llama-server |
|
||||
| 4 | Client LLM, `/v1` OpenAI, flux chat SSE |
|
||||
| 5 | Outils de l'agent, MCP, mode Code/LSP |
|
||||
| 6 | Modèles et téléchargements, Strata, optimiseur, bench |
|
||||
| 7 | Mémoire, tâches, dictée, relais, CDP |
|
||||
| 8 | Store bbolt → redb, retrait de Go |
|
||||
|
||||
Chaque lot aura son propre spec, plan et PR.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Migration Rust — lot 2 : UI statique, authentification, `/api/ping`
|
||||
|
||||
Date : 2026-10-13 · Statut : validé · Prérequis : lot 1 (façade `loki-rs`).
|
||||
|
||||
## But
|
||||
|
||||
Faire passer en Rust les premières routes réelles, et surtout poser le socle
|
||||
dont toutes les routes `/api/*` suivantes auront besoin : l'authentification.
|
||||
|
||||
## Périmètre
|
||||
|
||||
1. **UI statique servie par Rust**, embarquée dans le binaire à la
|
||||
compilation depuis `internal/loki/ui/` (même source que le `go:embed`) :
|
||||
|
||||
| Route | Content-Type | Cache-Control | Autre |
|
||||
|---|---|---|---|
|
||||
| `/`, `/index.html` | `text/html; charset=utf-8` | `no-store, max-age=0` | |
|
||||
| `/marked.min.js` | `application/javascript` | `public, max-age=86400` | |
|
||||
| `/sw.js` | `application/javascript` | `no-store, max-age=0` | `Service-Worker-Allowed: /` |
|
||||
| `/manifest.webmanifest` | `application/manifest+json` | `public, max-age=3600` | |
|
||||
| `/fonts/<nom>.woff2` | `font/woff2` | `public, max-age=31536000, immutable` | |
|
||||
|
||||
GET et HEAD seulement (les autres méthodes restent relayées au Go).
|
||||
Compression gzip si le client l'accepte, sauf polices (comme
|
||||
`web_gzip.go`) ; `Vary: Accept-Encoding` sur les types compressibles.
|
||||
Version compressée calculée une fois, à la première demande.
|
||||
|
||||
2. **API interne Go** `GET /internal/v1/auth` → `{"web_key": "…", "version": "…"}`.
|
||||
- Servie **uniquement sur l'écoute TCP directe** de `loki web`
|
||||
(enveloppe autour du mux dans `cmdWeb`) : le tunnel du relais sert le mux
|
||||
nu et ne la voit jamais.
|
||||
- Exige l'en-tête `X-Loki-Internal` égal à `LOKI_INTERNAL_TOKEN` (≥ 32
|
||||
caractères, comparaison en temps constant). Jeton absent, trop court ou
|
||||
faux → 404 (rien ne révèle l'existence de la route).
|
||||
- Base illisible → 503 : la façade fermera l'accès (jamais ouvert par
|
||||
défaut sur une panne d'E/S, même règle que `requireWebAuth`).
|
||||
- La façade tire un jeton aléatoire de 32 octets à chaque démarrage et le
|
||||
passe au Go par l'environnement.
|
||||
|
||||
3. **Authentification de pilotage en Rust** (`auth.rs`), port à l'identique de
|
||||
`requireWebAuth` + `crossSiteReject` + `localHostName` + `checkBearer` :
|
||||
- clé relue à **chaque requête** (API interne) : un changement de clé prend
|
||||
effet immédiatement ;
|
||||
- API interne en erreur (Go absent, 503, réponse illisible) → 503
|
||||
`{"error":"configuration illisible — réessaie dans un instant"}` ;
|
||||
- refus site tiers / hôte non local sans clé → 403 `{"error":"<raison>"}`
|
||||
(mêmes messages que le Go) ;
|
||||
- Bearer absent ou faux → 401 `{"error":"non autorisé"}` +
|
||||
`WWW-Authenticate: Bearer realm="loki"` ;
|
||||
- `X-Loki-Via` n'est jamais pris en compte côté Rust (effacé à l'entrée,
|
||||
le trafic du tunnel ne traverse pas la façade).
|
||||
|
||||
4. **`/api/ping` en Rust** : `{"ok":true,"service":"loki","version":"<version Go>"}`
|
||||
après authentification — première route `/api/*` servie par Rust.
|
||||
|
||||
5. **Durcissement de la façade** : `/internal/*` venant de l'extérieur → 404 ;
|
||||
en-tête `X-Loki-Internal` entrant effacé avant relais.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
Toutes les autres routes `/api/*`, `/v1`, le flux chat : lots suivants.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go : route interne (sans jeton → 404, mauvais jeton → 404, bon jeton → 200
|
||||
avec clé et version, autres chemins relayés au mux).
|
||||
- Rust : cas de `TestCrossSiteReject` repris un pour un ; `local_host_name`
|
||||
(IP, IPv6 entre crochets, nom sans point, suffixes locaux, hôte de
|
||||
confiance) ; Bearer ; statiques (types, cache, gzip, HEAD, police inconnue
|
||||
→ relayée) ; intégration façade + faux backend interne : ping 200 sans clé,
|
||||
401 avec clé sans Bearer, 200 avec bon Bearer, 503 si l'API interne tombe,
|
||||
403 rebinding, `/internal/v1/auth` depuis l'extérieur → 404.
|
||||
- Bout en bout local : vrai `loki` Go derrière la façade, `loki set-web-key`,
|
||||
ping avec et sans clé.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Migration Rust — lot 3 : supervision du moteur
|
||||
|
||||
Date : 2026-10-14 · Statut : livré · Prérequis : lots 1-2.
|
||||
|
||||
## Découpage
|
||||
|
||||
Le « moteur » en conteneur, c'est deux couches de taille très différente :
|
||||
|
||||
1. **La supervision du processus** (~350 lignes Go : `sys_service_container.go`,
|
||||
`sys_proctree_linux.go`) — lancer `loki serve` détaché, fichier PID,
|
||||
journal, arrêter tout l'arbre (Strata compris) et attendre que la VRAM
|
||||
soit rendue.
|
||||
2. **La ligne de commande** de llama-server / Strata (~9 000 lignes :
|
||||
`backend_serve*.go`, presets, MoE, spéculatif, threads, cache…), là où
|
||||
s'applique la règle « ne jamais dénaturer les modèles ».
|
||||
|
||||
Le lot 3 porte la couche 1. La couche 2 reste au Go (`loki serve`) et fera
|
||||
l'objet d'un lot dédié, parce que chaque option y a été réglée contre un
|
||||
défaut mesuré.
|
||||
|
||||
## Ce qui change
|
||||
|
||||
- `loki-rs engine start|stop|restart|status` : port à l'identique de la
|
||||
supervision Go — chef de session (`setsid`), fichier PID et journal aux
|
||||
**mêmes chemins** (`$LOKI_HOME/<service>.pid|.log`), contrôle à 2 s avec
|
||||
les 20 dernières lignes du journal en cas d'échec, arrêt de l'arbre
|
||||
(session, groupe, filiation ; SIGTERM, 4 s, SIGKILL, 20 s au plus), mêmes
|
||||
messages.
|
||||
- Le Go garde ses garde-fous (optimisation en cours via `serviceAction`,
|
||||
preset externe) puis délègue au `loki-rs` voisin de son binaire.
|
||||
`loki logs`, `enable`/`disable` restent au Go.
|
||||
- Porte de sortie : `LOKI_ENGINE_SUPERVISOR=go`, ou absence de `loki-rs`.
|
||||
- Différence assumée : le contrôle « démarré ? » regarde si l'enfant est
|
||||
sorti (`try_wait`), pas le signal 0 — un enfant mort reste un zombie, qui
|
||||
répond encore au signal 0.
|
||||
- L'arrêt exclut de l'arbre `loki-rs` lui-même ET son parent (le Go qui l'a
|
||||
appelé), comme le Go excluait son propre PID.
|
||||
|
||||
## Tests
|
||||
|
||||
- Rust (toutes plateformes) : `/proc/<pid>/stat` avec parenthèses dans le
|
||||
nom, arbre par session/groupe/filiation avec exclusion de l'appelant.
|
||||
- Rust (Linux, CI) : faux `loki serve` qui lance un enfant dans sa session et
|
||||
un « moteur » dans SA PROPRE session (comme Strata) — démarrage, « déjà
|
||||
démarré », arrêt de tout l'arbre, redémarrage, échec avec journal, PID
|
||||
nettoyé.
|
||||
- Go (Linux, CI) : choix du `loki-rs` voisin (absent, non exécutable,
|
||||
`LOKI_ENGINE_SUPERVISOR=go`), appel réel avec l'environnement attendu,
|
||||
erreur rendue = dernière ligne d'erreur du superviseur.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Migration Rust — lot 6 : le cœur chat
|
||||
|
||||
Date : 2026-10-14 · Statut : **livré** (étapes 1 à 6 ; défaut Rust depuis le 2026-10-15, `LOKI_CHAT=go` pour revenir au Go) ·
|
||||
Prérequis : lots 1-5 (façade, auth, store via API interne, `/v1`).
|
||||
|
||||
## Pourquoi c'est un lot à part
|
||||
|
||||
Le chat est le cœur de Loki et le seul chemin qu'on ne peut pas casser un
|
||||
soir de déploiement. ~17 700 lignes Go (`chat_*.go` 11 491, `llm_*.go`
|
||||
6 245), dont une fonction de 1 469 lignes (`runChatLoop`,
|
||||
`llm_client.go:1279`) qui mêle protocole du moteur, reprises sur erreur,
|
||||
compactage en cours de tour et exécution des outils.
|
||||
|
||||
## Carte (HEAD b98ee5c)
|
||||
|
||||
### Surface HTTP
|
||||
Toutes derrière `requireWebAuth` (déjà portée).
|
||||
|
||||
| Route | Handler | Rôle |
|
||||
|---|---|---|
|
||||
| `/api/chat` | `handleChat` web_chat.go:316 | **SSE** : rejoue le journal puis le suit en direct |
|
||||
| `/api/chat/send` | `handleChatSend` :178 | démarre un tour ou le met en file (409 occupé, 503 pas prêt) |
|
||||
| `/api/chat/stop`, `/reset`, `/state`, `/compact`, `/tool-result` | web_chat.go | contrôle et lecture |
|
||||
| `/api/chat/mode`, `/criteria` | code_mode.go | mode Code |
|
||||
| `/api/chat/export`, `/upload`, `/files*`, `/file*`, `/image`, `/cushot` | divers | pièces jointes, espace de travail, aperçu navigateur |
|
||||
| `/api/conversations{,/new,/switch,/rename,/delete}` | web_chat.go | index des discussions |
|
||||
| `/api/e2e/chat` | relay_e2e.go:143 | même flux, chiffré AES-GCM par événement (tunnel) |
|
||||
|
||||
Trame SSE : `data: {"choices":[{"delta":…}]}`, battement `: ping` toutes les
|
||||
4 s, regroupement à 128 Kio ou 256 événements.
|
||||
|
||||
### État en mémoire
|
||||
- `conv *Conversation` (chat_conversation.go:114) : `Messages` (vue du
|
||||
modèle), `Log []LogEvent{Seq,TS,Delta}` (journal d'affichage, **seule voie
|
||||
vers l'UI**, via `appendDelta`), `Seq`, `CtxUsed`, `Generating`, `cancel`,
|
||||
`epoch`, file d'attente, tâche en cours. Un `mu` + `cond`.
|
||||
- `Generating` est un **verrou global du moteur** partagé avec les tâches
|
||||
planifiées (`RunAutonomous`), le bench et l'optimiseur (`measureLease`) et
|
||||
quatre sondes `isGenerating`.
|
||||
- Persistance : `snapshot()` sérialise Messages ET Log dans un seul JSON,
|
||||
**chiffré** (`memEncoderNow`, clé en mémoire du Go après déverrouillage),
|
||||
écrit par un écrivain unique (`persistQ`) en une transaction bbolt —
|
||||
bucket `chat`, clés `conv:<id>`, `index`, `active`.
|
||||
|
||||
### Boucle d'un tour
|
||||
`handleChatSend` → `EnqueueOrStart` → `StartTurn` → `go generate` →
|
||||
`turnView` (prompt système, outils, MCP, mémoire, instantané projet) →
|
||||
`runChatTools` → `runChatLoop` (payload, slot, requête au moteur, analyse du
|
||||
flux, reprises, compactage réactif, **switch des outils inline**
|
||||
llm_client.go:2363-2549, compactage en cours de tour) → retour dans
|
||||
`generate` (validation mode Code `codeVerifyLoop`, compactage de fin de
|
||||
tour, persistance, file suivante, préchauffage).
|
||||
|
||||
## Coupures possibles
|
||||
|
||||
| | Coupure | Ce qui traverse | Risque |
|
||||
|---|---|---|---|
|
||||
| A | Journal + SSE en Rust, boucle au Go | flux de deltas Go→Rust, epoch, reset | Log persisté DANS le même blob chiffré que Messages |
|
||||
| B | Client LLM en Rust (protocole, flux, reprises) | appel d'outil Go en RPC, porte du moteur, compactage | fonction de 1 469 lignes, appels récursifs (sous-agents, vérificateur) |
|
||||
| C | Machine d'état de la conversation en Rust | turnView, compactage, outils, persistance en RPC | verrou `Generating` partagé avec tâches/bench/optimiseur |
|
||||
| D | Sessions + persistance | — | écrivain unique bbolt, chiffrement, ordre des résultats d'outils |
|
||||
|
||||
## Stratégie proposée
|
||||
|
||||
Pas de bascule partielle de la discussion de l'utilisateur : le cœur chat
|
||||
est réécrit **en entier** côté Rust, derrière `LOKI_CHAT=rust` (Go par
|
||||
défaut), puis comparé au Go sur des conversations rejouées avant de devenir
|
||||
le défaut. Étapes, chacune livrée et testée :
|
||||
|
||||
1. **Go, sans changement de comportement** : extraire le switch des outils en
|
||||
`execTool(ctx, name, args, caps) → résultat` et l'exposer en API interne
|
||||
(`/internal/v1/tool/exec`), ainsi que le chiffrement des blobs
|
||||
(`/internal/v1/crypt/seal|open`, la clé reste au Go) et le bail du moteur
|
||||
(`Generating` → `/internal/v1/engine/lease`).
|
||||
2. **Rust, bibliothèque** : protocole du moteur (payload OpenAI, analyse SSE
|
||||
llama.cpp/Strata, reasoning, appels d'outils natifs et textuels, effort,
|
||||
reprises réseau), testé contre des flux enregistrés.
|
||||
3. **Rust, conversation** : Messages + Log + epoch + file + SSE `/api/chat`
|
||||
(rejeu, pagination `historyCut`, `coalesceReplay`, `compactLog`),
|
||||
persistance au même format (`conv:<id>`, chiffré via l'étape 1).
|
||||
4. **Rust, boucle** : `runChatLoop` réécrit en machine d'état explicite,
|
||||
outils exécutés par le Go (étape 1), compactage porté.
|
||||
5. **Parité** : rejeu de conversations réelles exportées, comparaison des
|
||||
journaux produits (Go vs Rust) avec un faux moteur déterministe.
|
||||
6. **Bascule** `LOKI_CHAT=rust` par défaut ; outils, MCP, mémoire, mode Code
|
||||
portés ensuite (lot 7) en retirant les appels au Go un par un.
|
||||
|
||||
## Questions ouvertes
|
||||
- Chiffrement : port du format (`memEncoderNow`) en Rust à l'étape 3, ou
|
||||
RPC au Go jusqu'au lot 9 ?
|
||||
- Tunnel du relais (`/api/e2e/chat`) : reste au Go jusqu'au portage du
|
||||
relais ; tant que `LOKI_CHAT=rust`, le flux chiffré doit lire le journal
|
||||
Rust (API interne inverse) — à concevoir.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Lot 10 — réglages partagés tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Quatre routes ne font que lire et écrire des réglages en base, partagés entre
|
||||
tous les appareils :
|
||||
|
||||
| Route | Port de | Ce qu'elle règle |
|
||||
|---|---|---|
|
||||
| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) |
|
||||
| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:<id>`), repli sur le global (`state/sysprompt`) |
|
||||
| `GET\|POST /api/reasoning-effort` | `handleReasoningEffort` | `REASONING_EFFORT` de la configuration vivante (liste blanche) |
|
||||
| `GET\|POST /api/oai/public` | `web_public_url.go` | adresse publique normalisée en « schéma://hôte[:port] » |
|
||||
|
||||
Lot 10 : la façade les sert quand `LOKI_SETTINGS=rust` (`settings.rs`,
|
||||
expérimental ; sans la variable rien ne change).
|
||||
|
||||
Les valeurs restent dans la base du Go, aux mêmes clés. Le Go les relit là
|
||||
où il s'en sert (tour de chat, accès OpenAI) : son cache de configuration se
|
||||
périme sur l'horodatage du fichier de base, qu'une écriture de la façade
|
||||
modifie. `facade_settings.go` remet les routes du tunnel à la façade.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Message d'un corps JSON syntaxiquement illisible : celui de serde. Une
|
||||
valeur du mauvais type (`{"text":5}`) garde le message d'`encoding/json`.
|
||||
- Préférences réécrites clé par clé au lieu d'une transaction.
|
||||
- Port nommé dans l'adresse publique (`:https`) refusé : le Go le résolvait
|
||||
par `/etc/services`.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_settings_test.go` tire du Go :
|
||||
|
||||
- 19 adresses publiques normalisées ou refusées ;
|
||||
- 11 valeurs d'intensité ;
|
||||
- 29 appels de route, avec le contenu de la base après chacun :
|
||||
- fusion des préférences (valeurs refusées, prénom tronqué, vides effacés) ;
|
||||
- prompt par preset avec repli, effacement, méthode refusée ;
|
||||
- intensité acceptée ou refusée ;
|
||||
- adresse publique.
|
||||
|
||||
`tests/settings.rs` rejoue le tout par la façade. Une mutation (prénom
|
||||
tronqué à 25 caractères) fait échouer le rejeu.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Lot 11 — fichiers de la discussion tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les fichiers rangés par discussion vivaient dans le Go :
|
||||
|
||||
- `web_upload.go` : dépôt, téléchargement, image ;
|
||||
- `web_files.go` : panneau Fichiers ;
|
||||
- `web_files_zip.go` : archive ;
|
||||
- `web_thumb.go` et `web_upload_orient.go` : vignettes ;
|
||||
- `chat_convfiles.go` : chemins.
|
||||
|
||||
Lot 11 : la façade les sert quand `LOKI_FILES=rust` (`convfiles.rs`,
|
||||
expérimental ; sans la variable rien ne change).
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `POST /api/chat/upload` | dépôt en morceaux base64 (8 Mio), fichier de 1 Gio au plus, espace disque vérifié, `.part` renommé à la fin sous un nom sûr et unique |
|
||||
| `GET /api/chat/files` | panneau : dossiers d'abord puis du plus récent au plus ancien, tailles récursives (liens non suivis), vue « hors discussion » |
|
||||
| `POST /api/chat/file/delete` | suppression ; refus de la racine et du dossier des discussions |
|
||||
| `GET /api/chat/file` | fiche, tranche base64 (tunnel E2E) ou brut en pièce jointe (plage d'octets) |
|
||||
| `GET /api/chat/image` | type reniflé sur le contenu, en ligne sous une CSP fermée ; vignette JPEG `thumb=<px>` redressée (EXIF) et réduite par moyennage |
|
||||
| `GET /api/chat/files/zip` | archive construite à la fiche, puis servie en tranches ou en brut |
|
||||
|
||||
Le dossier de la discussion active reste décidé par le Go : la façade le lui
|
||||
demande à chaque requête par la nouvelle route `/internal/v1/conv/workspace`
|
||||
(`{dir, root, id, title}`). Il connaît la discussion active, qu'il tienne la
|
||||
conversation ou que ce soit la façade. Les liens des anciens messages
|
||||
(`captures/<id>/…`, `uploads/…` à la racine) restent résolus comme avant.
|
||||
|
||||
`facade_files.go` remet les routes du tunnel à la façade. Il ne vide plus les
|
||||
`.part` d'envois au démarrage : la façade le fait au sien.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Octets des vignettes JPEG : l'encodeur diffère. Les dimensions, le
|
||||
redressement et la réduction (algorithme porté pixel pour pixel) sont les
|
||||
mêmes.
|
||||
- Horodatage des entrées `.zip` en UTC.
|
||||
- Une seule plage d'octets par requête de téléchargement : le Go en acceptait
|
||||
plusieurs, en multipart.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_files_test.go` tire du Go :
|
||||
|
||||
- 10 noms de dépôt et 6 slugs ;
|
||||
- 6 en-têtes EXIF ;
|
||||
- les 8 orientations d'une image de 5×3 et 4 réductions, pixel pour pixel ;
|
||||
- 10 reniflages ;
|
||||
- 42 appels de route sur une arborescence fixe, dates comprises :
|
||||
- panneau, téléchargement, image et vignette ;
|
||||
- dépôt en un morceau, en deux morceaux, envoi expiré, fichier vide,
|
||||
base64 illisible ;
|
||||
- suppressions ;
|
||||
- archive de la discussion et « hors discussion », contenu comparé
|
||||
entrée par entrée ;
|
||||
- les fichiers restants après chaque POST.
|
||||
|
||||
`tests/convfiles.rs` rejoue le tout par la façade, plus une plage d'octets,
|
||||
un lien vers l'extérieur (refusé, non compté) et le nom d'archive tiré du
|
||||
titre. Deux mutations (ordre du panneau, suffixe des doublons) font échouer
|
||||
le rejeu.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Lot 12 — notifications Web Push tenues par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les notifications « réponse prête » vivaient dans le Go : `push.go`,
|
||||
`web_push.go` et webpush-go. Lot 12 : la façade les tient quand
|
||||
`LOKI_PUSH=rust` (`push.rs`, expérimental ; sans la variable rien ne change).
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/push/key` | clé publique VAPID, générée au premier appel puis stable |
|
||||
| `POST /api/push/subscribe` | enregistre un abonnement (idempotent par endpoint, clés mises à jour) |
|
||||
| `POST /api/push/unsubscribe` | le retire |
|
||||
| `POST /internal/facade/v1/push/send` | (le Go, jeton interne) pousse une notification à tous les abonnés, en tâche de fond |
|
||||
|
||||
Les envois du Go passent tous par `sendPushToAll` : fin de tour du chat Go,
|
||||
fin de tour tenu par la façade (`/internal/v1/turn/done`), fin de tâche. Ils
|
||||
sont délégués à la façade. Clés et abonnements restent en base aux mêmes
|
||||
clés (`push_vapid_priv`, `push_vapid_pub`, `push_subs`).
|
||||
|
||||
Chiffrement RFC 8291 (`aes128gcm`), enregistrement complété à 4096 octets
|
||||
comme webpush-go. Signature VAPID RFC 8292 (JWT ES256, 12 h, sujet
|
||||
« mailto: »). TTL de 120 s, urgence haute. Un abonnement refusé pour de bon
|
||||
(404/410) est purgé. La cryptographie passe par ring, déjà présent via
|
||||
rustls. Une façade construite sans TLS répond que les notifications sont
|
||||
indisponibles.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Messages des journaux d'envoi au format des crates Rust.
|
||||
|
||||
## Parité
|
||||
|
||||
- `internal/loki/parity_push_test.go` tire du Go :
|
||||
- les durées du corps de notification ;
|
||||
- onze appels de route (clé, abonnements valides et refusés, mise à jour
|
||||
des clés, désabonnements), avec la liste en base après chacun.
|
||||
|
||||
`tests/push.rs` rejoue le tout par la façade. La liste en base est
|
||||
comparée à l'octet près, et la clé reste la même d'un appel à l'autre.
|
||||
- Le chiffrement reproduit l'exemple de l'annexe A de la RFC 8291 à l'octet
|
||||
près.
|
||||
- Un envoi de bout en bout vers un faux service de push vérifie :
|
||||
- les en-têtes ;
|
||||
- la signature VAPID, contrôlée avec la clé rangée ;
|
||||
- le corps, déchiffré côté navigateur avec la clé de l'abonnement ;
|
||||
- l'abonnement en 410, purgé.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Lot 13 — mémoire tenue par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La mémoire de Loki vivait dans le Go :
|
||||
- `chat_memory.go`, `mem_index.go`, `mem_io.go` et `mem_fsutil.go` pour les pages ;
|
||||
- `moveMemPage` (`projects.go`) pour le déplacement entre projets ;
|
||||
- `tracker.go` et `web_tracker.go` pour les trackers ;
|
||||
- les routes du mode agent.
|
||||
|
||||
Lot 13 : la façade tient ces routes quand `LOKI_MEM=rust` (`memory.rs`,
|
||||
expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/mem?name=` | contenu d'une page (modèle de page neuve sans nom) |
|
||||
| `POST /api/mem/save` | crée, écrase ou renomme une page ; l'index suit |
|
||||
| `POST /api/mem/delete` | supprime une page ; sa ligne d'index part avec elle |
|
||||
| `POST /api/mem/move` | déplace une page vers un autre projet ; les deux index suivent |
|
||||
| `GET /api/agent` (alias `/api/tools`, `/api/skills`) | mode agent, compactage, mode mémoire, liste des pages |
|
||||
| `POST /api/agent/toggle` (et alias) | mode agent (`state/agent`) |
|
||||
| `POST /api/agent/compact` | compactage automatique (`COMPACT`) |
|
||||
| `GET|POST /api/memory` | mode mémoire du projet actif, repli sur `MEM_MODE` |
|
||||
| `GET|POST /api/tracker` | panneau des trackers : liste, détail, ajout, modification, suppression, renommage, déplacement |
|
||||
|
||||
### Disque et base
|
||||
|
||||
Les pages restent des fichiers `memory/<projet>/<nom>.md`, et l'index
|
||||
`MEMORY.md` reste en clair. Le Go continue de les lire et de les écrire pour
|
||||
sa propre boucle : outils `mem_*`, index injecté au modèle. Les écritures
|
||||
restent sûres, comme dans le Go :
|
||||
- un `.bak` de la version précédente est gardé ;
|
||||
- le fichier est écrit dans un temporaire, synchronisé, puis renommé ;
|
||||
- le contenu est relu et comparé octet par octet.
|
||||
|
||||
Les trackers restent dans le bucket `tracker`, sous la clé
|
||||
`<projet>/<slug>`. Le registre des projets (`state/projects`) est lu tel
|
||||
quel. Le mode mémoire y est écrit avec l'échappement HTML de
|
||||
`encoding/json`, pour garder les mêmes octets en base.
|
||||
|
||||
### Chiffrement
|
||||
|
||||
La clé de la mémoire ne quitte pas le Go :
|
||||
- une page qui porte le magic `LOKIMEMv1` est ouverte par
|
||||
`/internal/v1/crypt/open` ;
|
||||
- toute écriture de page ou de tracker passe par `/internal/v1/crypt/seal`,
|
||||
qui rend le clair tel quel quand le chiffrement est coupé.
|
||||
|
||||
Quand la mémoire est verrouillée :
|
||||
- les pages restent listées avec le titre « 🔒 (chiffré — mémoire
|
||||
verrouillée) » ;
|
||||
- les trackers illisibles sont tus ;
|
||||
- aucune écriture ne retombe en clair.
|
||||
|
||||
### Messages d'erreur JSON
|
||||
|
||||
Ces routes renvoient au client le message d'erreur de décodage du Go.
|
||||
`gojson.rs` reproduit `json.NewDecoder(r.Body).Decode(&req)` :
|
||||
- seule la première valeur compte ;
|
||||
- `EOF` et `unexpected EOF` ;
|
||||
- les erreurs du scanner (`invalid character …`) ;
|
||||
- les erreurs de type, champs anonymes compris ;
|
||||
- la casse des noms de champs, et l'allocation des pointeurs.
|
||||
|
||||
`gojson.rs` est mutualisé pour les lots suivants.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
- Le coffre et le chiffrement (`/api/mem/encrypt|decrypt|unlock|lock`).
|
||||
- La santé, les instantanés, la sauvegarde.
|
||||
- Le hub des projets (`/api/projects`) : sa bascule recharge la discussion.
|
||||
- La bascule temporaire de projet d'une tâche planifiée. Elle n'existe qu'en
|
||||
RAM du Go : la façade sert toujours le projet actif rangé.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Un corps JSON syntaxiquement valide mais qui fait échouer serde (UTF-8
|
||||
invalide, nombre hors limites) donne le message de serde.
|
||||
- Une date d'un tracker qui tombe dans un trou d'heure d'été est résolue par
|
||||
`mktime`.
|
||||
|
||||
## Parité
|
||||
|
||||
### Décodage JSON
|
||||
|
||||
`internal/loki/parity_gojson_test.go` tire du Go 81 corps décodés : champs
|
||||
et message d'erreur. `tests/gojson.rs` les rejoue.
|
||||
|
||||
### Mémoire
|
||||
|
||||
`internal/loki/parity_mem_test.go` tire du Go :
|
||||
- les noms de pages, les titres, les slugs et les modes mémoire ;
|
||||
- 36 dates de trackers, lues dans un fuseau fixe (UTC+2) ;
|
||||
- 102 appels de route, avec après chacun le dossier `memory/` complet
|
||||
(`.bak` compris) et les clés touchées en base (mode agent, compactage,
|
||||
mode mémoire, projets, trackers).
|
||||
|
||||
Ces appels couvrent :
|
||||
- les pages : création, renommage, réenregistrement qui perd l'accroche,
|
||||
noms refusés, fichiers cachés et dossiers ignorés ;
|
||||
- le déplacement, avec ses six refus ;
|
||||
- la bascule de projet ;
|
||||
- le mode agent et le compactage ;
|
||||
- le mode mémoire, y compris un projet actif inconnu ;
|
||||
- les trackers : ajout, modification, suppression, renommage, déplacement,
|
||||
valeurs illisibles et événements nuls.
|
||||
|
||||
`tests/memory.rs` rejoue le tout par la façade, avec le même masquage des
|
||||
identifiants et des instants « maintenant ».
|
||||
|
||||
Une mutation de chaque harnais fait échouer son rejeu.
|
||||
|
||||
Un essai de bout en bout simule le chiffrement par le faux Go :
|
||||
- les pages sont écrites scellées et relues en clair ;
|
||||
- l'index reste en clair ;
|
||||
- les trackers sont scellés ;
|
||||
- une fois la mémoire verrouillée, rien n'est réécrit en clair.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 14 — `loki serve` composé en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 3, la supervision du moteur est en Rust : `loki-rs engine
|
||||
start|stop|restart`. Mais elle lançait encore `loki serve` (Go), qui compose
|
||||
la ligne de commande de llama-server avant de lui céder la place.
|
||||
|
||||
Cette ligne vit dans `backend_serve*.go`, environ 4 000 lignes. Chaque option
|
||||
y a été réglée contre un défaut mesuré :
|
||||
- couches GPU et placement automatique (`--fit`, `--fit-target`) ;
|
||||
- threads et sonde de conteneur ;
|
||||
- files de lancement CUDA ;
|
||||
- second slot, parallélisme de tenseurs ;
|
||||
- cache de prompts, points de reprise des hybrides ;
|
||||
- décodage spéculatif (MTP, brouillon, n-grammes) ;
|
||||
- garde-fous de fidélité, garde-fou de chargement (LOAD_GUARD) ;
|
||||
- état des slots gardé à la bascule.
|
||||
|
||||
Lot 14 : `loki-rs engine serve` fait ce travail quand `LOKI_SERVE=rust`
|
||||
(expérimental). Le superviseur le lance à la place de `loki serve`. Sans la
|
||||
variable, rien ne change.
|
||||
|
||||
## Découpage
|
||||
|
||||
- `serve/` : port fonction par fonction. `mod.rs` contient
|
||||
`build_serve_args` et les outils de drapeaux. Les modules suivent les
|
||||
fichiers Go :
|
||||
- `threads`, `queues`, `expert`, `fidelity` ;
|
||||
- `side`, `split`, `cache`, `ckpt` ;
|
||||
- `spec`, `moe`, `persist`.
|
||||
|
||||
Tout ce qui décide est pur.
|
||||
- `serve/run.rs` porte `cmdServe`. Il lit la configuration, la clé d'API, les
|
||||
dossiers de modèles et le jeton de tentative de SPEC=auto par l'API interne
|
||||
du Go. Il sonde ensuite :
|
||||
- l'aide du moteur, `--version`, `--list-devices` (bornés dans le temps,
|
||||
avec les bibliothèques du moteur) ;
|
||||
- le GGUF ;
|
||||
- la RAM, la VRAM (nvidia-smi), le cgroup et le cpuset ;
|
||||
- les variables `LLAMA_ARG_*` et `GGML_*`.
|
||||
|
||||
Il vérifie que le port est libre et écrit le marqueur de SLOT_PERSIST. Il
|
||||
garde la commande en base pour l'interface (`engine_cmdline`), puis fait
|
||||
`exec` du moteur.
|
||||
- La façade passe au Go l'adresse de son API interne (`LOKI_GO_ADDR`), à côté
|
||||
du jeton. Le superviseur, lancé par le Go, en hérite, et `engine serve` aussi.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
`loki serve` (Go) est lancé à notre place dans ces cas, à l'identique :
|
||||
- un preset Strata (son propre serveur, lot à venir) ;
|
||||
- le moteur d'essai de l'optimiseur (`LOKI_TUNE_TRIAL`) ;
|
||||
- l'absence d'API interne (lancement à la main hors de la façade).
|
||||
|
||||
Restent aussi au Go les aperçus de l'éditeur de preset (`/api/preset/cacheram`,
|
||||
`/api/preset/moe`, « Dupliquer en placement auto »). Ils viendront avec le lot
|
||||
des presets.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Le message d'un brouillon (`MODEL_DRAFT`) introuvable cite l'erreur système
|
||||
au format Rust.
|
||||
- Pas de cache de l'aide ni du build par binaire : `engine serve` ne compose
|
||||
qu'une ligne par lancement, le cache du Go ne servait qu'au process web.
|
||||
- Le jeton de tentative de SPEC=auto est lu puis réécrit par l'API interne,
|
||||
sans transaction : le Go seul l'efface, une fois le moteur prêt.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_serve_test.go` tire du Go 1 872 lancements, chacun
|
||||
avec sa ligne de commande, son environnement et ses notes. Les lancements
|
||||
croisent :
|
||||
- 12 machines (VRAM, RAM, GGUF dense, MoE, hybride, par couches, architecture
|
||||
exclue, brouillons, mémoire unifiée, conteneur à l'étroit) ;
|
||||
- 112 presets ;
|
||||
- 3 aides de moteur (récent, ancien, inconnu) ;
|
||||
- 13 jeux de variables `LLAMA_ARG_*`, et des variables `GGML_*`.
|
||||
|
||||
Le journal comprend aussi les sondes pures :
|
||||
- listes de CPU, cpuset et quotas, mémoire du cgroup ;
|
||||
- sortie de nvidia-smi, `--list-devices`, build du moteur ;
|
||||
- forme copiable de la commande ;
|
||||
- verdicts de SPEC=auto ;
|
||||
- drapeaux de chargement, taille d'état par jeton.
|
||||
|
||||
`tests/serve.rs` rejoue le tout contre `serve::build_serve_args`, argument
|
||||
par argument. Une mutation du calcul du cache, des files CUDA ou des bornes
|
||||
des n-grammes fait échouer le rejeu.
|
||||
|
||||
Deux essais de bout en bout lancent le vrai `loki-rs engine serve` contre un
|
||||
faux Go et un faux llama-server. Ils vérifient :
|
||||
- la ligne exécutée et le chemin des bibliothèques ;
|
||||
- la commande gardée en base ;
|
||||
- le refus d'un port pris, d'un modèle absent ;
|
||||
- la sortie propre d'un preset externe ;
|
||||
- la délégation au Go (sans API interne, essai de l'optimiseur, Strata).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Lot 15 — presets tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les presets vivaient dans le Go :
|
||||
- `backend_presets.go` pour l'écriture ;
|
||||
- les handlers de `web_api.go` et `web_external.go` ;
|
||||
- les aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`,
|
||||
`moePreview`, `autoPlacementPreset`).
|
||||
|
||||
Lot 15 : la façade tient ces routes quand `LOKI_PRESETS=rust`
|
||||
(`presets_api.rs`, expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/presets` | liste, avec pastilles (contexte, vision, quantification, raisonnement, Strata, externe) et mesure du bench si elle correspond à la configuration actuelle |
|
||||
| `POST /api/presets/order` | ordre choisi par glisser-déposer |
|
||||
| `GET /api/preset?id=` | contenu et prompt système ; sans id, la graine d'un preset neuf (BIN personnalisé, HOST, PORT) |
|
||||
| `POST /api/preset/save` | crée (nom de fichier unique) ou met à jour ; le preset EN SERVICE est appliqué tout de suite |
|
||||
| `POST /api/preset/delete` | refusé pour le preset actif ; emporte prompt système et mesure, et le modèle sur demande |
|
||||
| `POST /api/preset/cacheram`, `/api/preset/moe` | aperçus de l'éditeur, calculés comme au lancement (lot 14) |
|
||||
| `POST /api/preset/autoplace` | copie en placement automatique d'un preset MoE placé à la main |
|
||||
| `GET /api/preset/external?id=`, `POST /api/preset/external/save`, `/api/preset/external/test` | presets externes ; la clé n'est jamais rendue, le test fait un vrai appel de complétion |
|
||||
| `POST /api/switch` | bascule de preset |
|
||||
|
||||
Les presets restent des fichiers `presets/*.env`, et leur prompt système une
|
||||
clé de la base. Les aperçus réutilisent le port de `loki serve` (lot 14),
|
||||
sondes comprises.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Tout ce qui touche au moteur reste au Go, jusqu'au lot qui le portera. Le Go
|
||||
expose deux routes internes pour cela (`facade_presets.go`).
|
||||
|
||||
`POST /internal/v1/preset/apply {path, mode}` applique un preset à la
|
||||
configuration vivante :
|
||||
- `switch` : l'état du slot est relevé avant (SLOT_PERSIST), puis le moteur
|
||||
est relancé, ou arrêté pour un preset externe ;
|
||||
- `save` : application, puis relance ;
|
||||
- `apply` : la configuration seule (preset externe en service modifié).
|
||||
|
||||
`GET /internal/v1/tune/guard` dit si une optimisation tient le moteur. La
|
||||
réponse est alors un 409 `{ok: false, tuning: true}`, comme le Go.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- La suppression d'un preset retire sa mesure sans réécrire les autres avec
|
||||
leurs champs à zéro, comme le faisait la struct du Go. Le Go les relit
|
||||
pareil.
|
||||
- Le message d'un échec de réseau du test externe est celui de reqwest.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_presets_test.go` tire du Go deux séries.
|
||||
|
||||
Les fonctions pures :
|
||||
- nom d'affichage, guillemets, format `.env`, noms refusés ;
|
||||
- quantification, raisonnement, modèle ;
|
||||
- URL de complétions, contenu d'un preset externe, message d'erreur d'API ;
|
||||
- arguments rejoints, clé réécrite dans un preset ;
|
||||
- huit copies en placement auto ;
|
||||
- empreinte de mesure, moteur imposé par un preset.
|
||||
|
||||
Puis 70 appels de route, avec après chacun les dossiers `presets/` et
|
||||
`models/`, et les clés touchées en base. Ils couvrent :
|
||||
- créations, doublons, noms refusés, erreurs JSON ;
|
||||
- ordre, pastilles et mesures (empreinte, repli sur le modèle, autre
|
||||
configuration) ;
|
||||
- preset actif et suppressions (modèle compris, partagé, absent) ;
|
||||
- presets externes et leur test contre un faux service (succès, 401 au
|
||||
format OpenAI, 404 brut, 500 vide) ;
|
||||
- placement auto et aperçus.
|
||||
|
||||
`tests/presets_api.rs` rejoue le tout par la façade, et une mutation fait
|
||||
échouer le rejeu.
|
||||
|
||||
La bascule et l'enregistrement du preset en service sont vérifiés de bout en
|
||||
bout contre le faux Go :
|
||||
- appels d'application, avec leur mode ;
|
||||
- refus quand une optimisation tourne ;
|
||||
- erreur d'application rendue ;
|
||||
- prompt système.
|
||||
|
||||
Côté Go, `TestFacadePresetApply` vérifie `presetApply` : configuration
|
||||
appliquée, appareils préservés, moteur relancé, chemin hors du dossier
|
||||
refusé.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Lot 16 — pilotage du moteur tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le Go pilotait le moteur depuis l'interface :
|
||||
- `svcHandler` (`web_api.go`) pour `/api/start`, `/api/stop` et
|
||||
`/api/restart` ;
|
||||
- `handleVramUnload` et `handleVramReload` (`web_vram.go`) pour rendre la
|
||||
carte graphique à la machine, puis la reprendre ;
|
||||
- `serviceAction` (`sys_service.go`) : le verrou de l'optimiseur, puis, en
|
||||
conteneur, le superviseur Rust `loki-rs engine <action>` (lot 3,
|
||||
`sys_service_rust_linux.go`) ;
|
||||
- `preflightEngine` : ce sans quoi le moteur ne peut pas démarrer, dit avant
|
||||
de le lancer.
|
||||
|
||||
Lot 16 : la façade tient ces routes quand `LOKI_ENGINE=rust`
|
||||
(`enginectl.rs`, expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `/api/start`, `/api/stop`, `/api/restart` | pilotage du moteur ; réponse `{ok, out}` comme le Go |
|
||||
| `POST /api/vram/unload` | arrête le moteur et la dictée, rapporte la VRAM rendue ; refusé pendant une réponse en cours sauf `{force: true}` |
|
||||
| `POST /api/vram/reload` | relance le moteur après le contrôle d'avant démarrage ; `{already: true}` s'il tourne |
|
||||
|
||||
La façade lance elle-même `loki-rs engine <action>`, avec l'environnement que
|
||||
le Go lui passait (`LOKI_GO_BIN`, `LOKI_HOME`, `LOKI_SERVICE`) et de quoi
|
||||
joindre l'API interne du Go pour `loki-rs engine serve` (`LOKI_GO_ADDR`,
|
||||
`LOKI_INTERNAL_TOKEN`). Sa dernière ligne d'erreur devient l'erreur rendue,
|
||||
sans le préfixe du binaire. Un preset externe ne lance rien ; un redémarrage
|
||||
arrête alors le moteur local.
|
||||
|
||||
Le bilan de VRAM suit `gpuSettle` :
|
||||
- une lecture ratée est sautée, jamais comptée « 0 Mo » ;
|
||||
- un palier ne conclut qu'une fois la baisse commencée ;
|
||||
- les jauges du moniteur sont invalidées après coup.
|
||||
|
||||
## Quand la façade ne tient pas ce lot
|
||||
|
||||
La façade ne prend ce lot qu'en conteneur, avec son superviseur. Sous
|
||||
systemd, ou avec `LOKI_ENGINE_SUPERVISOR=go`, `loki-rs` ne passe pas
|
||||
`LOKI_ENGINE` au Go, qui garde les routes (décidé au démarrage, raison dans
|
||||
le journal).
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le Go expose trois routes internes (`facade_engine.go`) :
|
||||
- `POST /internal/v1/service/prestart` : avant tout démarrage, refus pendant
|
||||
une optimisation, puis ménage d'un verrou périmé (essai orphelin arrêté,
|
||||
application interrompue défaite). Le verrou de l'optimiseur suit le
|
||||
processus qui le tient, il reste au Go jusqu'au lot de l'optimiseur ;
|
||||
- `GET /internal/v1/chat/generating` : la réponse en cours, quand le Go tient
|
||||
la conversation (sinon la façade lit la sienne) ;
|
||||
- `POST /internal/v1/dictate/stop` : l'arrêt de la dictée, quand le Go la
|
||||
tient (sinon la façade arrête la sienne).
|
||||
|
||||
`GET /internal/v1/tune/guard` (lot 15) sert le refus 409 du rechargement.
|
||||
|
||||
Les autres appelants de `serviceAction` côté Go (bascule de preset,
|
||||
optimiseur, mises à jour du moteur) passent toujours par le Go. Les deux voies
|
||||
lancent le même superviseur, sur les mêmes fichiers PID et journal.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- `engineNeedsStop` regarde aussi les états transitoires de systemd. En
|
||||
conteneur, seul cas où la façade tient ce lot, il se réduit au PID vivant.
|
||||
- Un superviseur tué par un signal est décrit comme par le Go (« signal:
|
||||
killed ») pour les signaux courants, par son numéro sinon.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_engine_test.go` tire du Go :
|
||||
- dix bilans de VRAM (palier avant la baisse, lectures ratées, VRAM en
|
||||
hausse…) ;
|
||||
- la dernière ligne d'erreur du superviseur et le retrait des couleurs ;
|
||||
- vingt-six installations passées au contrôle d'avant démarrage (preset
|
||||
externe, Strata, BIN absent ou relatif, MODEL absent, hors des dossiers,
|
||||
remontant, en tranches incomplètes…) ;
|
||||
- dix réponses de route qui ne lancent rien (GET refusé avec `Allow: POST`,
|
||||
optimisation ou réponse en cours, moteur déjà arrêté, contrôle en échec).
|
||||
|
||||
`tests/enginectl.rs` rejoue le tout par la façade. Il vérifie aussi de bout en
|
||||
bout, avec un faux superviseur et un faux nvidia-smi :
|
||||
- démarrage, arrêt, redémarrage, refus pendant une optimisation ;
|
||||
- échec du démarrage, preset externe ;
|
||||
- déchargement chiffré, arrêt en échec, VRAM en hausse ;
|
||||
- rechargement.
|
||||
|
||||
Une mutation de chaque règle fait échouer les tests.
|
||||
|
||||
Côté Go, `TestFacadeMoteur` vérifie le relais et les trois routes internes.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lot 17 — interrupteurs système tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Quatre réglages de l'interface vivaient dans le Go :
|
||||
- l'accès internet de l'IA : `handleInternet`, `chat_internet.go`,
|
||||
`web_fetch_go.go` (moteur web, serveur Crawl4AI, clé, joignabilité) ;
|
||||
- le pilotage du navigateur : `handleComputer`, `chromePath`,
|
||||
`visionEnabled` ;
|
||||
- la clé de complétion du moteur : `handleAPIKey`, `backend_config.go`,
|
||||
`oaiBaseURL` ;
|
||||
- l'écoute réseau du moteur : `handleNetwork`, `sys_network.go`.
|
||||
|
||||
Lot 17 : la façade tient ces routes quand `LOKI_SYSTEM=rust` (`system.rs`,
|
||||
expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET\|POST /api/internet` | interrupteur, moteur web (`go` ou `crawl4ai`), URL et clé du serveur Crawl4AI (la clé n'est jamais rendue, seulement ses quatre derniers caractères), joignabilité |
|
||||
| `GET\|POST /api/computer` | interrupteur du navigateur, binaire trouvé, vision du modèle |
|
||||
| `GET\|POST /api/apikey` | clé de complétion : génération, saisie, effacement ; adresse `/v1` à coller dans un client tiers |
|
||||
| `GET\|POST /api/network` | moteur ouvert au réseau ou à cette machine seulement (`HOST`), adresse de l'endpoint |
|
||||
|
||||
Les réglages restent des clés de la base, écrites comme le Go : une chaîne
|
||||
vide efface la clé, et l'URL comme les clés ne laissent rien traîner dans la
|
||||
configuration.
|
||||
|
||||
La joignabilité de Crawl4AI suit `crawlReachable` :
|
||||
- `/health`, puis `/`, en moins de deux secondes en tout ;
|
||||
- clé envoyée en `Authorization: Bearer` ;
|
||||
- moins de 500 = joignable ;
|
||||
- cache de 30 s par URL, vidé quand l'URL, la clé ou le moteur changent.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le Go expose trois routes internes (`facade_system.go`) :
|
||||
- `POST /internal/v1/web/reach/reset` : le cache de joignabilité des outils
|
||||
web, encore exécutés par le Go, est vidé à chaque changement ;
|
||||
- `POST /internal/v1/cdp/shutdown` : le Chromium des outils `browser_*` se
|
||||
ferme quand l'interrupteur se coupe ;
|
||||
- `POST /internal/v1/service/action {action, if_active}` : une clé neuve ne
|
||||
s'applique qu'au redémarrage du moteur. La façade le relance elle-même si
|
||||
elle le pilote (lot 16), sinon le Go, seulement s'il tourne.
|
||||
|
||||
`GET /internal/v1/tune/guard` (lot 15) refuse un changement de clé pendant une
|
||||
optimisation.
|
||||
|
||||
Le relais du tunnel vers la façade garde désormais l'hôte et
|
||||
`X-Forwarded-Proto` du client : l'adresse de la clé de complétion est celle
|
||||
que le client a tapée, pas celle de la façade.
|
||||
|
||||
## Correction en passant
|
||||
|
||||
`maskAPIKey` paniquait sur une clé d'un seul octet (`k[len(k)-2:]`).
|
||||
`/api/apikey` tombait alors à chaque lecture tant que la clé restait en place.
|
||||
Une clé d'un ou deux octets s'affiche maintenant « … », côté Go comme côté
|
||||
Rust.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Hors Windows, aucun pare-feu n'est piloté : la façade ne tourne qu'en
|
||||
conteneur Linux, où le Go répondait déjà « inconnu ».
|
||||
- La clé masquée est coupée sur les octets, comme le Go. Un caractère coupé
|
||||
devient U+FFFD, octet par octet, comme son encodeur JSON.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_system_test.go` tire du Go 61 appels de route, avec
|
||||
après chacun les clés touchées en base et les en-têtes reçus par trois faux
|
||||
serveurs Crawl4AI (200, 500, 404). Ils couvrent :
|
||||
- moteurs inconnus, URL avec et sans slashs, injoignable, sans schéma ;
|
||||
- clés longues, courtes, d'un caractère, résiduelle en configuration ;
|
||||
- erreurs de type qui laissent passer les autres champs, `null` ;
|
||||
- vision par projecteur, preset externe ou Strata ;
|
||||
- clé générée (masquée dans le journal), saisie, de treize octets, repli sur
|
||||
`config.env`, adresse publique, `X-Forwarded-Proto`, refus pendant une
|
||||
optimisation ;
|
||||
- écoute ouverte, fermée, `localhost`, `::1`, port illisible.
|
||||
|
||||
Il tire aussi huit dispositions du dossier de Playwright pour le Chromium
|
||||
retenu.
|
||||
|
||||
`tests/system.rs` rejoue le tout par une seule façade, cache de
|
||||
joignabilité compris. Il vérifie de bout en bout ce qui reste au Go : cache
|
||||
vidé, navigateur fermé, moteur relancé par le Go, ou par le superviseur
|
||||
quand la façade pilote le moteur. Une mutation de chaque règle fait échouer
|
||||
les tests.
|
||||
|
||||
Côté Go :
|
||||
- `TestFacadeSysteme` vérifie le relais et les trois routes internes ;
|
||||
- `TestFacadeRelaisGardeHote` vérifie l'hôte gardé ;
|
||||
- `TestMaskAPIKeyCourte` vérifie le masque des clés courtes.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Lot 18 — hub des projets tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le hub des projets vivait dans le Go :
|
||||
- `handleProjects` et `projectDTOs` (`web_projects.go`) ;
|
||||
- les mutations de `projects.go` : `createProject`, `renameProject`,
|
||||
`setProjectDesc`, `deleteProject` ;
|
||||
- `projectSwitch` (`chat_sessions.go`).
|
||||
|
||||
Lot 18 : la façade tient `/api/projects` quand `LOKI_PROJECTS=rust`
|
||||
(`projects_api.rs`, expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Action | Rôle |
|
||||
|---|---|
|
||||
| `GET` | les projets, le plus ancien d'abord, avec leurs discussions, pages (`.md` visibles, index exclu) et trackers comptés ; le projet actif |
|
||||
| `create` | slug translittéré (`Réseau NAS` → `reseau-nas`), unique (`-2`, `-3`…), `projet` à défaut, 40 octets au plus ; nom de 60 runes au plus ; dossier `memory/<slug>` et son index amorcé |
|
||||
| `rename` | le libellé seul : slug, chemins et discussions ne bougent pas |
|
||||
| `desc` | 2 000 runes au plus, vide = effacée |
|
||||
| `switch` | la conversation passe sur le projet (la plus récente de ses discussions, ou une neuve) |
|
||||
| `delete` | discussions, trackers et mémoire du projet ; le projet actif est réassigné ; le dernier projet est indestructible |
|
||||
|
||||
Le registre reste dans la base (`state/projects`, écrit comme `json.Marshal`
|
||||
avec le HTML échappé), la mémoire de chaque projet dans `memory/<slug>`.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le projet actif passe toujours par le Go (`/internal/v1/project/activate`),
|
||||
qui en garde une copie en RAM pour la mémoire, les outils et les tâches.
|
||||
|
||||
Les discussions sont à qui tient la conversation.
|
||||
|
||||
Quand la façade la tient (`LOKI_CHAT=rust`), elle bascule et supprime
|
||||
elle-même. Avant de supprimer le projet actif, elle enregistre sa discussion
|
||||
et en ouvre une du premier projet restant.
|
||||
|
||||
Sinon le Go garde deux routes internes (`facade_projects.go`) :
|
||||
- `POST /internal/v1/project/switch` : `projectSwitch` (discussion du projet
|
||||
quitté enregistrée, plus récente du projet rejoint ouverte, ou une neuve) ;
|
||||
- `POST /internal/v1/project/drop-convs` : les discussions du projet
|
||||
supprimées (`convDelete`, fichiers et résultats compris).
|
||||
|
||||
Une génération en cours refuse la bascule et la suppression (409). La façade
|
||||
lit la sienne, ou celle du Go (`/internal/v1/chat/generating`, lot 16).
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Pendant une tâche planifiée, le Go voit comme actif le projet de la tâche ;
|
||||
la façade lit le projet actif enregistré.
|
||||
- Pendant un tour de la façade, une bascule vers un projet inconnu répond 409,
|
||||
comme quand le Go tient la conversation. Le Go, relayant à la façade,
|
||||
répondait 400 (« projet introuvable ») dans ce cas.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_projects_test.go` part d'une base amorcée :
|
||||
- pages visibles, cachées, hors `.md`, dans un sous-dossier ;
|
||||
- discussions de Générale, d'avant les projets, d'un projet à venir et d'un
|
||||
projet disparu ;
|
||||
- trackers, dont `alphabet/x` qui ne doit pas tomber avec `alpha`.
|
||||
|
||||
Il tire du Go 40 appels, avec après chacun le registre, le projet actif,
|
||||
l'arbre `memory/`, les trackers et l'index des discussions. Ils couvrent :
|
||||
- créations (doublons, translittération, sans lettres, longues, vides) ;
|
||||
- renommages et descriptions (longues, coupées sur une espace, effacées) ;
|
||||
- bascules (sur l'actif, inconnue, vers un projet sans discussion, pendant
|
||||
une génération) ;
|
||||
- suppressions (de l'actif, inconnue, pendant une génération, jusqu'au
|
||||
dernier projet) ;
|
||||
- actions inconnues, corps vide ou illisible, erreur de type, méthode
|
||||
refusée.
|
||||
|
||||
`tests/projects_api.rs` rejoue le tout par la façade, contre un faux Go qui
|
||||
bascule et supprime les discussions comme le vrai. La bascule et la
|
||||
suppression quand la façade tient la conversation sont vérifiées de bout en
|
||||
bout. Une mutation de chaque règle fait échouer les tests.
|
||||
|
||||
Côté Go, `TestFacadeProjets` vérifie le relais et les deux routes internes.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Lot 19 — export, mode Code et critères de la discussion
|
||||
|
||||
## Contexte
|
||||
|
||||
Avec `LOKI_CHAT=rust`, la façade tient la conversation (lot 6), mais trois
|
||||
routes de la discussion ouverte restaient au Go :
|
||||
- `/api/chat/export` (`chat_export.go`). Il relisait la copie enregistrée,
|
||||
qui peut avoir un temps de retard sur la conversation vivante ;
|
||||
- `/api/chat/mode` (`code_mode.go`) : mode Chat ou Code de la discussion, et
|
||||
ses critères ;
|
||||
- `/api/chat/criteria` : critères édités à la main.
|
||||
|
||||
Lot 19 : ces trois routes rejoignent celles de la conversation. La façade
|
||||
les sert quand elle la tient (`chatx.rs`, `facadeChatRoutes` côté Go), sans
|
||||
nouvelle variable. Sans `LOKI_CHAT=rust`, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/chat/export` | Markdown (fait pour être lu) ou JSON (fait pour être retraité), tiré de la conversation vivante ; options `format`, `reasoning`, `tools`, `results`, `turns` |
|
||||
| `GET\|POST /api/chat/mode` | mode de la discussion ouverte (`chat/mode:<id>`, absent = chat) et ses critères ; un changement part sur le fil (`{mode}`) |
|
||||
| `POST /api/chat/criteria` | critères édités : vides écartés, vingt au plus, ids posés, statuts ramenés à `pending`/`passed`/`failed` ; scellés comme le contenu des discussions (`chat/crit:<id>`), sur le fil (`{criteria, ts}`) |
|
||||
|
||||
L'export suit `chat_export.go` :
|
||||
- Markdown : un bloc par tour, le raisonnement replié dans un `<details>`,
|
||||
les outils en liste (seul l'état final d'un outil compte, comme au
|
||||
rejeu), la compaction signalée, un export partiel annoncé en tête ;
|
||||
- JSON : indenté et HTML échappé comme `json.MarshalIndent`, vue modèle sans
|
||||
mise à jour de contexte (`PROJ_SNAPSHOT`) ni image en base64 (élidée avec
|
||||
sa taille), journal filtré par les mêmes cases ;
|
||||
- les derniers échanges comptent à partir des messages de l'utilisateur, pas
|
||||
des rappels de Loki.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le reste du mode Code reste au Go et lit les mêmes clés : l'indice « ça
|
||||
ressemble à du code », les travaux de fond et l'outil `criteria` de l'agent.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- L'export part de la conversation vivante, pas de sa copie enregistrée :
|
||||
le dernier tour y est, même tout juste fini.
|
||||
- Les images d'un tour en cours restent dans l'export JSON, élidées. Une
|
||||
conversation relue par le Go les avait déjà perdues.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_chatx_test.go` tire du Go l'export d'une conversation
|
||||
riche sous 22 jeux d'options lus dans la requête. La conversation contient :
|
||||
- pièces jointes, raisonnement en deltas ;
|
||||
- outils finis, en cours, avec et sans sortie, labels piégés pour le
|
||||
Markdown ;
|
||||
- statistiques, compaction ;
|
||||
- images en base64 et en URL, rappel de Loki ;
|
||||
- mise à jour de contexte marquée, et une tapée par l'utilisateur.
|
||||
|
||||
Plus les exports d'une conversation vide.
|
||||
|
||||
Il tire aussi 23 appels aux routes du mode et des critères, avec la base et
|
||||
les événements du fil après chacun : erreurs JSON, y compris celles d'un
|
||||
critère mal typé (`Criterion.criteria.id`), et au-delà de vingt critères.
|
||||
|
||||
`tests/chatx.rs` rejoue l'export octet pour octet et les routes par la
|
||||
façade. Il vérifie aussi les en-têtes du téléchargement et le maintien des
|
||||
routes au Go sans la conversation. Une mutation de chaque règle fait échouer
|
||||
les tests.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Lot 20 — serveurs MCP tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le Go tenait les serveurs MCP (Model Context Protocol) :
|
||||
- `mcp_config.go` : la configuration, dans `state/mcp` ;
|
||||
- `mcp_catalog.go` : le catalogue embarqué ;
|
||||
- `mcp_client.go` : le pool de sessions, avec le SDK MCP Go ;
|
||||
- les routes `/api/mcp*` de `web_api.go`.
|
||||
|
||||
Le pool sert deux usages : l'état affiché dans l'interface, et les outils
|
||||
`mcp__<serveur>__<outil>` annoncés au modèle et exécutés pour l'agent.
|
||||
|
||||
Lot 20 : la façade tient tout cela quand `LOKI_MCP=rust` (`mcp/`,
|
||||
expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/mcp` | état de chaque serveur, connexion tentée pour les activés (en parallèle) |
|
||||
| `GET /api/mcp/catalog` | catalogue embarqué (le même fichier que le Go), `{workspace}` résolu, entrées déjà configurées marquées, lanceur (npx, uvx) présent ou non |
|
||||
| `POST /api/mcp/save` | ajoute ou remplace (actif par défaut) ; nom contrôlé, un seul transport |
|
||||
| `POST /api/mcp/delete`, `/api/mcp/toggle`, `/api/mcp/tool` | retire, active ou éteint, masque ou démasque un outil |
|
||||
| `POST /api/mcp/test` | reconnexion forcée |
|
||||
|
||||
## Client MCP en Rust
|
||||
|
||||
`mcp/client.rs` parle JSON-RPC 2.0 sur deux transports, comme le SDK Go :
|
||||
- stdio : un processus local, un message par ligne ;
|
||||
- Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE,
|
||||
session suivie par `Mcp-Session-Id`, en-têtes configurés ajoutés.
|
||||
|
||||
La session s'ouvre par `initialize` puis `notifications/initialized`. Elle
|
||||
sert `tools/list` (première page, comme le Go) et `tools/call`. Un `ping` du
|
||||
serveur reçoit sa réponse.
|
||||
|
||||
Le pool (`mcp/mod.rs`) suit `mcp_client.go` :
|
||||
- une session gardée d'un tour à l'autre ;
|
||||
- une entrée publiée avant la connexion, pour que deux appelants partagent
|
||||
la même tentative ;
|
||||
- une session invalidée pendant sa connexion n'est jamais republiée ;
|
||||
- délai de 3 min pour npx et uvx (premier téléchargement), 20 s sinon ;
|
||||
- un appel borné à 120 s, une reconnexion si la session est morte ;
|
||||
- la sortie plafonnée à la fenêtre de contexte (12 000 au moins), une erreur
|
||||
d'outil signalée, une sortie vide rendue « [ok] (aucune sortie) ».
|
||||
|
||||
## Le Go
|
||||
|
||||
Un seul pool, celui de la façade. Le Go, qui exécute encore les outils de
|
||||
l'agent, lui demande les outils à annoncer, leurs appels et le
|
||||
pré-chauffage (`/internal/facade/v1/mcp/{tools,call,prewarm}`,
|
||||
`facade_mcp.go`), quelle que soit la conversation qui tourne. Son propre
|
||||
pool reste vide.
|
||||
|
||||
L'image copie `internal/loki/mcp_catalog.json` dans l'étape de compilation
|
||||
Rust : un seul catalogue pour les deux binaires.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs de transport (connexion refusée, flux coupé) ont les messages
|
||||
du client Rust. Une erreur JSON-RPC et un binaire introuvable ont ceux du
|
||||
Go.
|
||||
- Un contenu « autre » (lien de ressource, ressource intégrée) est rendu en
|
||||
JSON dans l'ordre des champs du SDK. Les annotations éventuelles sont dans
|
||||
l'ordre alphabétique.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_mcp_test.go` tire du Go :
|
||||
- les fonctions pures : noms exposés, schémas, libellés d'appel, serveur
|
||||
d'origine d'un outil, ligne du préambule, délais et leurs messages,
|
||||
aplatissement de neuf résultats ;
|
||||
- la lecture de la configuration et seize mutations avec leurs erreurs et la
|
||||
base après chacune ;
|
||||
- ce qu'un vrai serveur du SDK MCP Go met sur le fil en Streamable HTTP :
|
||||
onze outils, et le résultat de chacun ;
|
||||
- ce que le client Go en tire : état de quatre serveurs (joignable,
|
||||
binaire introuvable, désactivé, mal configuré), outils annoncés (un
|
||||
masqué), appels.
|
||||
|
||||
`tests/mcp.rs` rejoue les fonctions et la configuration. Un faux serveur
|
||||
resert ce que le serveur du SDK a répondu (appels en SSE) : l'état, les
|
||||
outils et les appels tirés par la façade doivent être ceux du Go, sur une
|
||||
seule session. Sont aussi vérifiés de bout en bout :
|
||||
- le transport stdio : environnement, ping du serveur, serveur mort en plein
|
||||
appel puis reconnexion ;
|
||||
- les routes de configuration, en-têtes compris ;
|
||||
- le catalogue ;
|
||||
- une session invalidée pendant sa connexion, sans processus laissé
|
||||
derrière.
|
||||
|
||||
Une mutation de chaque règle fait échouer les tests. Côté Go,
|
||||
`TestFacadeMCP` vérifie le relais, les outils, les appels, le
|
||||
pré-chauffage et une façade injoignable.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Lot 21 — mise à jour de Loki tenue par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
`/api/update` (version disponible) et `/api/update/apply` (téléchargement et
|
||||
remplacement du binaire) vivaient dans `sys_update.go`. En conteneur
|
||||
(`LOKI_CONTAINER=1`), le Go n'y fait rien. L'image se met à jour par Docker,
|
||||
et les deux routes le disent (`containerUpdateNote`) :
|
||||
- `/api/update` : `{current, available: false, note}` ;
|
||||
- `/api/update/apply` : 409 `{ok: false, error: note}`.
|
||||
|
||||
## Le lot
|
||||
|
||||
La façade ne tourne qu'en conteneur. Elle tient ces deux routes quand
|
||||
`LOKI_UPDATE=rust` (`sysupdate.rs`, expérimental) et `LOKI_CONTAINER=1`.
|
||||
Sinon `loki-rs` ne passe pas la variable au Go, qui garde le téléchargement
|
||||
et le remplacement du binaire hors conteneur.
|
||||
|
||||
La version annoncée est celle du Go (`/internal/v1/auth`). Les binaires de
|
||||
la façade suivent toujours `loki-rs self-update`.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_update_test.go` tire du Go les quatre réponses en
|
||||
conteneur (GET et POST sur chaque route). `tests/sysupdate.rs` les rejoue par
|
||||
la façade, version du Go comprise.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Lot 22 — backends llama.cpp et job des moteurs tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le panneau Moteur s'appuie sur une famille de routes du Go :
|
||||
- `web_llamacpp.go` : statut, vérification git, bascule fast/opt, job ;
|
||||
- `backend_prebuilt.go` : binaires précompilés officiels ;
|
||||
- `web_api.go` : backends custom et leur désinstallation ;
|
||||
- `web_devices.go` : GPU vus par un moteur.
|
||||
|
||||
Elles partagent **un** job (`lcJob`) avec la mise à jour du moteur de l'image
|
||||
(`web_engine.go`) et l'installation de Strata (`backend_strata.go`). L'entête
|
||||
du job est en base (`state/build_job`), ses lignes dans
|
||||
`$LOKI_HOME/loki-build.log`. Au démarrage, un job noté « en cours » est déclaré
|
||||
interrompu (`lcRestore`).
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_BACKENDS=rust` (expérimental), la façade (`rust/loki-rs/src/backends`)
|
||||
sert les routes suivantes :
|
||||
|
||||
| Route | Rôle |
|
||||
| --- | --- |
|
||||
| `/api/llamacpp` | statut : dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build, conseil, job |
|
||||
| `/api/llamacpp/check` | `git fetch`, puis le retard sur origin |
|
||||
| `/api/llamacpp/prebuilt/check` | dernière release officielle comparée à la version installée |
|
||||
| `/api/llamacpp/prebuilt` | job : téléchargement, extraction (liens compris), VERSION, ménage, BIN, service arrêté puis relancé |
|
||||
| `/api/llamacpp/use` | bascule fast/opt, refusée pendant une optimisation |
|
||||
| `/api/llamacpp/job` | progression et lignes à partir d'un décalage |
|
||||
| `/api/llamacpp/uninstall-custom` | désinstallation d'un backend custom |
|
||||
| `/api/backends`, `/api/backends/custom` | les backends présents |
|
||||
| `/api/backends/devices` | GPU vus par un moteur, avec un cache mémoire et un cache disque |
|
||||
|
||||
**Le job vit dans la façade** (`backends/job.rs`) : même entête en base, même
|
||||
journal, même reprise au démarrage. Les jobs que le Go fait encore tourner lui
|
||||
rapportent chaque étape par l'API interne `/internal/facade/v1/build-job/*`
|
||||
(`start`, `line`, `phase`, `fail`, `done`, `commits`, `end`, `snapshot`).
|
||||
Ces jobs sont :
|
||||
- la compilation depuis les sources (`install`, `update`, `install-custom`,
|
||||
relayées au Go qui garde son refus 409 quand l'image fournit le moteur) ;
|
||||
- la mise à jour du moteur de l'image ;
|
||||
- Strata.
|
||||
|
||||
Côté Go (`facade_backends.go`), quand la façade tient le lot :
|
||||
- `startLcJob`, `lcAppend`, `lcPhase`, `lcFail`, `lcDone`, `lcSetCommit` et
|
||||
`lcJobSnapshot` passent par la façade ;
|
||||
- le garde de l'optimiseur reste au Go, avant le démarrage ;
|
||||
- le Go ne restaure plus de job au démarrage ;
|
||||
- les routes reçues par le tunnel sont relayées à la façade.
|
||||
|
||||
Pour arrêter et relancer le service, la façade pilote le moteur si elle le
|
||||
tient (lot 16), sinon elle le demande au Go (`/internal/v1/service/action`,
|
||||
avec `if_active`). Les sondes de la machine passent par un PATH et une racine
|
||||
injectables (`backends/probe.rs`) :
|
||||
- `nvidia-smi`, `nvcc`, `hipcc`, `/opt/rocm` ;
|
||||
- `glslc`, `vulkaninfo`, `ldconfig` et les chemins de `libvulkan`.
|
||||
|
||||
## Correction du Go
|
||||
|
||||
`prebuiltVersionFormat` lisait le format comme 3ᵉ champ du marqueur VERSION.
|
||||
Hors CUDA Windows, la version CUDA est vide : le marqueur
|
||||
« `b300 fmt2` » n'a alors que deux champs. Le format n'était donc jamais
|
||||
reconnu, et chaque vérification retéléchargeait la release sous Linux et
|
||||
macOS au lieu de dire « déjà à jour ». Le format est désormais le dernier
|
||||
champ, et la version CUDA ignore ce champ. Le port Rust suit.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_backends_test.go` (Linux) tire du Go un journal rejoué
|
||||
par `tests/backends.rs`. Le journal est sauté si `/usr/local/cuda*` ou
|
||||
`/opt/rocm` existent sur la machine.
|
||||
|
||||
Le journal contient :
|
||||
- **le statut** dans tous ses états :
|
||||
- dépôt git reproductible (dates et identité fixées), retard connu ou vérifié, tête détachée ;
|
||||
- origine perdue, BIN dans un fork ;
|
||||
- précompilé versionné, moteur téléchargé, moteur de l'image ;
|
||||
- **les backends custom** et chaque refus de désinstallation ;
|
||||
- **la bascule fast/opt** ;
|
||||
- **la dernière release** :
|
||||
- refus HTTP, tag vide, JSON illisible ;
|
||||
- aucun asset adapté ;
|
||||
- **le job précompilé** :
|
||||
- installation, « déjà à jour », ancien format réextrait ;
|
||||
- optimisation en cours, GitHub qui refuse ;
|
||||
- archive absente, archive piégée (`../`), archive sans serveur ;
|
||||
- **les GPU** :
|
||||
- liaison PCIe, mémoire complétée par nvidia-smi ;
|
||||
- cache, mémoire nulle non figée, repli sur le disque ;
|
||||
- moteur planté, tué ou non exécutable, pilote ancien, cartes de même nom ;
|
||||
- **après chaque appel** : BIN, entête en base, journal, `devices.json` et l'arborescence de `backends/` et `engine/` ;
|
||||
- **machine par machine** (dix fausses machines) : le plan de build, le conseil et la variante de release ;
|
||||
- **le job pas à pas** :
|
||||
- phases déduites des lignes de build, troncature à 4000 lignes, vues à partir d'un décalage ;
|
||||
- écritures en base limitées à une toutes les 2 s ;
|
||||
- refus d'un second job ;
|
||||
- reprise au démarrage dans tous les cas : absent, illisible, fini, interrompu, journal vide ou trop long.
|
||||
|
||||
Autres tests :
|
||||
- le Go (`TestFacadeBackends`) vérifie que ses jobs rapportent chaque étape à
|
||||
la façade, dans l'ordre, et que la compilation n'est pas relayée ;
|
||||
- la façade (`api_interne_du_job`, `service_arrete_puis_relance`) vérifie
|
||||
l'API interne et le service arrêté puis relancé autour de l'installation.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs réseau (GitHub injoignable) gardent le texte de `reqwest`.
|
||||
- Quand un asset de la release a un champ de mauvais type, le message n'est
|
||||
pas celui du Go.
|
||||
- `git fetch` lancé par `/api/llamacpp/check` pendant un job du Go n'écrit
|
||||
plus ses lignes dans ce job.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Lot 23 — sonde de gabarit portée en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
La sonde de gabarit (`chat_tplprobe.go`) dit ce que le gabarit de chat du
|
||||
modèle chargé fait de ce qu'on lui envoie :
|
||||
|
||||
- `preserves_history` : le raisonnement d'un tour passé est-il encore rendu ?
|
||||
- `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
|
||||
préfixe exact du suivant ?
|
||||
- `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
|
||||
|
||||
Le Go la lance après chaque complétion du fil principal, avec les outils et
|
||||
les `chat_template_kwargs` de la requête. Trois choses lisent son verdict :
|
||||
|
||||
- la politique d'écho du raisonnement (`REASONING_ECHO`) ;
|
||||
- le rappel de budget glissé dans le résultat d'outil (`NUDGE_IN_TOOL`) ;
|
||||
- le résumé des performances (`/api/perf/summary`).
|
||||
|
||||
Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
|
||||
servent le panneau Moteur, autour d'une mise à jour.
|
||||
|
||||
Quand la façade tenait la conversation (`LOKI_CHAT=rust`), plus aucune sonde
|
||||
ne tournait :
|
||||
|
||||
- l'écho décidait sans verdict ;
|
||||
- `NUDGE_IN_TOOL` gardait la conversation au Go (`facadeChatBlocker`).
|
||||
|
||||
## Le lot
|
||||
|
||||
`rust/loki-rs/src/chat/tplprobe.rs` porte la sonde entière :
|
||||
|
||||
- les requêtes au moteur local (`/health`, `/props`, `/apply-template`, avec la
|
||||
clé du moteur et les relances sur 503) ;
|
||||
- les rendus synthétiques et leurs verdicts ;
|
||||
- le cache par build, modèle, gabarit et forme ;
|
||||
- le dernier verdict, global et par forme ;
|
||||
- la ligne de journal ;
|
||||
- le déclenchement borné (une sonde en vol au plus, une par minute) ;
|
||||
- le relevé du rendu et sa comparaison.
|
||||
|
||||
Avec `LOKI_CHAT=rust` :
|
||||
|
||||
- **la boucle du tour** signale chaque complétion lue en entier sur le moteur
|
||||
local, outils ni coupés ni neutralisés (`TurnHost::completed`). L'hôte lance
|
||||
la sonde pour le fil principal seulement : ni tâche ni sous-agent ;
|
||||
- **la politique d'écho** lit le dernier verdict ;
|
||||
- **`NUDGE_IN_TOOL`** est porté (`nudge_in_tool_key`, `nudge_into_tool`,
|
||||
`TurnHost::prefix_unstable`). Il ne garde plus la conversation au Go ;
|
||||
- **le résumé des performances**, que sert encore le Go, relit le verdict de la
|
||||
façade (`/internal/facade/v1/chat/tpl`, `facadeTplCaps`).
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tplprobe_test.go` enregistre chaque échange de la sonde
|
||||
du Go avec un faux moteur : méthode, chemin, clé, corps envoyé, réponse.
|
||||
|
||||
Les comportements de moteur couverts :
|
||||
|
||||
- gabarit Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce
|
||||
ignorée ;
|
||||
- 404, exception du gabarit, clé exigée (inconnue ou connue) ;
|
||||
- 503 relancés ou persistants, 429 ;
|
||||
- `/props` illisible, moteur pas prêt, réponse sans prompt ;
|
||||
- rendus incohérents, preset externe.
|
||||
|
||||
Le journal contient aussi :
|
||||
|
||||
- le verdict, sa ligne de journal, `tplCapsCurrent` et `tplCapsFor` ;
|
||||
- le cache : même moteur, gabarit changé, build changé ;
|
||||
- le relevé du rendu sur trois gabarits, et une erreur ;
|
||||
- neuf comparaisons de relevés ;
|
||||
- l'empreinte de six formes.
|
||||
|
||||
`tests/tplprobe.rs` rejoue chaque échange contre le port. Le faux moteur rend
|
||||
les réponses enregistrées et exige des requêtes identiques, octet pour octet.
|
||||
Il vérifie aussi le déclenchement borné.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- `tests/lot6.rs` vérifie la sonde lancée après un vrai tour de la façade ;
|
||||
- les tests de la boucle vérifient le rappel dans le résultat d'outil et les
|
||||
complétions signalées ;
|
||||
- `TestFacadeSondeDeGabarit` vérifie, côté Go, que le résumé des performances
|
||||
relit le verdict de la façade.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Lot 24 — panneau « moteur de l'image » tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le panneau Moteur permet de mettre à jour llama.cpp sans reconstruire
|
||||
l'image. Le moteur est pris dans l'image de conteneur officielle
|
||||
(`ghcr.io/ggml-org/llama.cpp:server-cuda-b#####`), dont on ne télécharge que
|
||||
les couches du sommet, celles qui portent `/app`. Trois fichiers du Go le
|
||||
servent :
|
||||
|
||||
- `web_engine.go` : statut, dernier build publié, mise à jour, bascule,
|
||||
suppression ;
|
||||
- `web_engine_reco.go` : version recommandée, risque de rendu du
|
||||
raisonnement (défaut de b10763), retour à la version précédente,
|
||||
vérification du rendu du gabarit avant/après la bascule ;
|
||||
- `backend_engine_oci.go` : dialogue avec le registre, extraction des
|
||||
couches, essai à blanc.
|
||||
|
||||
La mise à jour est un job, le job des moteurs que la façade tient depuis le
|
||||
lot 22. Le risque de rendu et la vérification du rendu reposent sur la sonde
|
||||
de gabarit, portée au lot 23.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_ENGINE_IMAGE=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la
|
||||
façade (`rust/loki-rs/src/engineimg`) sert les routes suivantes :
|
||||
|
||||
| Route | Rôle |
|
||||
| --- | --- |
|
||||
| `/api/engine` | statut : binaire, build, source, variante, versions installées, avis, encart « version recommandée », version précédente, relevé du rendu |
|
||||
| `/api/engine/check` | dernier build publié pour la variante, comparé au courant |
|
||||
| `/api/engine/update` | job : recherche, téléchargement des couches du sommet, essai à blanc, REASONING_PRESERVE=off si demandé, bascule, ménage, vérification du rendu |
|
||||
| `/api/engine/use` | bascule vers une version installée ou vers le moteur de l'image |
|
||||
| `/api/engine/remove` | suppression d'une version installée (refusée pour celle qui tourne) |
|
||||
| `/api/engine/plan` | sur clic : version visée (dernière ou recommandée) et risque de rendu |
|
||||
| `/api/engine/rollback` | retour au moteur d'avant la dernière bascule |
|
||||
|
||||
Sans les backends, loki-rs ne passe pas la variable au Go : le panneau
|
||||
reste au Go et le dit au démarrage. `/api/engine/cmdline` reste au Go.
|
||||
|
||||
La sonde de gabarit est celle de la conversation quand la façade la tient
|
||||
(`LOKI_CHAT=rust`) : le relevé du rendu rejoue alors la forme de ses vraies
|
||||
requêtes. Sinon, la façade a sa propre sonde, qui sonde une requête nue.
|
||||
|
||||
Pour REASONING_PRESERVE=off posée dans le preset actif, la façade réutilise
|
||||
l'enregistrement du lot 15 (`save_applying`). Le service est arrêté puis
|
||||
relancé comme au lot 22 : par la façade si elle pilote le moteur, sinon par
|
||||
le Go (`/internal/v1/service/action`).
|
||||
|
||||
Côté Go, `facade_engineimg.go` relaie à la façade les routes reçues par le
|
||||
tunnel.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_engineimg_test.go` (Linux) tire du Go un journal
|
||||
rejoué par `tests/engineimg.rs`. Il tourne contre :
|
||||
|
||||
- un faux registre OCI : jeton, index multi-architecture, manifeste annoté,
|
||||
couches .tar.gz ;
|
||||
- un faux moteur local (le `fakeTpl` du Go, porté) pour la sonde et le rendu.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- **le statut** : moteur de l'image, téléchargé ou compilé, avec la version
|
||||
précédente (absente quand c'est le moteur courant), le relevé du rendu,
|
||||
sans moteur d'image, sans aucun moteur ;
|
||||
- **le dernier build** : publié, sans numéro, ou refusé (jeton refusé ou
|
||||
illisible, manifeste refusé ou illisible, aucune image pour
|
||||
l'architecture) ;
|
||||
- **le plan** :
|
||||
- dernière version (avec ou sans numéro) ou recommandée (build trop
|
||||
ancien, tag figé absent, sans numéro), et déjà à jour ;
|
||||
- chaque verdict du risque de rendu : moteur arrêté, gabarit vide,
|
||||
REASONING_PRESERVE posée, EXTRA_ARGS, gabarit sans les variables, gabarit
|
||||
qui garde la réflexion, build antérieur au défaut, preset externe ;
|
||||
- **la mise à jour** :
|
||||
- couches du sommet vers la base ; une couche du dessus masque celle du
|
||||
dessous ; liens absolus ramenés dans `/app` ; marqueurs de suppression et
|
||||
sous-dossiers ignorés ; couche sans fichier du moteur ; couche du runtime
|
||||
ignorée ; phase du téléchargement ;
|
||||
- essai à blanc ;
|
||||
- risque de rendu rappelé dans le journal, établi ou impossible à vérifier
|
||||
(moteur local arrêté) ;
|
||||
- REASONING_PRESERVE=off : posée dans le preset actif ou dans la
|
||||
configuration, déjà posée, ou inconnue du moteur ;
|
||||
- ménage : versions triées par build, dossier sans moteur épargné, version
|
||||
précédente gardée ;
|
||||
- rendu comparé avant/après : changé, identique, ou impossible sur le
|
||||
nouveau moteur ;
|
||||
- déjà à jour, corps illisible ;
|
||||
- **les échecs** :
|
||||
- optimisation en cours, version inconnue, image sans serveur ou sans
|
||||
version annoncée, manifeste sans couche, runtime au sommet, couche
|
||||
introuvable, tag illisible ;
|
||||
- moteur qui ne démarre pas (12 dernières lignes de sa sortie) ou qui ne
|
||||
voit aucune carte ;
|
||||
- **les bascules** : vers une version installée, vers l'image, vers une
|
||||
version absente, pendant une optimisation ; retour, deux fois, et en GET ;
|
||||
- **la suppression** : version (`../` neutralisé), version absente, moteur
|
||||
utilisé ;
|
||||
- **après chaque appel** : BIN, REASONING_PRESERVE, `engine/previous-bin` et
|
||||
l'arborescence de `engine/`.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- la façade vérifie le service arrêté puis relancé autour d'une mise à jour
|
||||
et redémarré par une bascule (`service_arrete_puis_relance`), le relevé
|
||||
« non vérifié à temps », le 503 sans backends, les variantes et les
|
||||
vérifications périmées ;
|
||||
- le Go (`TestFacadeMoteurImage`) vérifie que les sept routes partent à la
|
||||
façade, et elles seulement.
|
||||
|
||||
## Mutations
|
||||
|
||||
74 mutations du port Rust, toutes tuées sauf trois, équivalentes :
|
||||
|
||||
- la garde « moteur d'image absent » de la source : `same_path` ne
|
||||
rapproche jamais un chemin vide ;
|
||||
- l'essai à blanc qui relance l'inventaire des cartes quand l'ancien moteur
|
||||
est le nouveau : même résultat ;
|
||||
- une vérification du rendu périmée qui continue d'attendre son moteur : son
|
||||
relevé est de toute façon écarté au rangement.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs réseau (registre injoignable) gardent le texte de `reqwest`.
|
||||
- Un manifeste dont un champ a le mauvais type ne donne pas le message du
|
||||
Go : le nom Go du type anonyme n'est pas reproduit.
|
||||
- La vérification du rendu attend son délai à la cadence choisie (5 s), sans
|
||||
le contexte Go qui coupe aussi une requête en cours au bout de 20 min.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Lot 25 — Strata tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Strata est le deuxième moteur de Loki. Il répartit les experts d'un MoE de
|
||||
125 B (famille Qwen3.8-Flash-Next) entre VRAM, RAM et disque. Le moteur est
|
||||
le paquet **figé** publié par AJEAN : les sources et le binaire Linux CUDA 12,
|
||||
vérifiés par SHA-256. Un preset le choisit (`ENGINE=strata`).
|
||||
|
||||
`backend_strata.go` sert trois routes :
|
||||
|
||||
- `/api/strata` : la machine (cartes, RAM, disque, Python), ce qui y tient,
|
||||
la recommandation par version et les modèles installés, avec leur
|
||||
configuration de lancement en clair ;
|
||||
- `/api/strata/settings` : les réglages d'un modèle installé. S'il est en
|
||||
service, la configuration est réappliquée et le moteur relancé ;
|
||||
- `/api/strata/install` : un job des moteurs. Il télécharge et vérifie le
|
||||
paquet, lance l'installeur de Strata, écrit le preset et bascule dessus.
|
||||
|
||||
Le lancement (`loki serve` d'un preset Strata) vit aussi dans ce fichier.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_STRATA=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la façade
|
||||
(`rust/loki-rs/src/strata`) sert les trois routes :
|
||||
|
||||
- **la décision pure** (`strata/mod.rs`) :
|
||||
- placement des experts : drop, ram, arena ou disk, selon la RAM
|
||||
disponible, la carte d'appoint et `experts.bin` ;
|
||||
- configuration de lancement : réglages validés, carte d'aide, mode des
|
||||
experts, réglages du preset ;
|
||||
- ce qui tient sur la machine, la recommandation, les réglages, les noms ;
|
||||
- **la machine** (`strata/api.rs`) :
|
||||
- cartes via `nvidia-smi` (la sonde des backends) ;
|
||||
- Python 3.10+ et son module venv ;
|
||||
- RAM totale et disponible (`/proc/meminfo`) ;
|
||||
- RAM du moteur Strata en marche (arbre du service dans `/proc`) ;
|
||||
- place libre ;
|
||||
- **l'installation** : le job des moteurs. La sortie de l'installeur est
|
||||
découpée en lignes comme le `sinkWriter` du Go : une barre `\r` n'est
|
||||
montrée que toutes les 10 s. La bascule finale passe par le Go
|
||||
(`/internal/v1/preset/apply`, mode `switch`), qui applique le preset et
|
||||
relance le service.
|
||||
|
||||
Sans les backends, loki-rs ne passe pas la variable au Go et Strata reste au
|
||||
Go. Le lancement reste au Go (`loki serve`, que `loki-rs engine serve` lui
|
||||
délègue pour un preset Strata).
|
||||
|
||||
Côté Go, `facade_strata.go` relaie à la façade les routes reçues par le
|
||||
tunnel.
|
||||
|
||||
## Correction du Go
|
||||
|
||||
`/api/strata/settings` décidait si le preset était en service **après** y
|
||||
avoir écrit les nouveaux réglages. L'empreinte du nouveau contenu ne
|
||||
correspondait alors plus à la configuration active : changer un réglage du
|
||||
modèle en service ne l'appliquait jamais, et le moteur n'était pas relancé.
|
||||
« En service » se décide désormais avant l'écriture, comme dans
|
||||
`SavePresetApplying`. Le port Rust suit.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_strata_test.go` (Linux x86_64) tire du Go un journal
|
||||
rejoué par `tests/strata.rs`. Pour le tirer, le Go reçoit des crochets de
|
||||
test : `strataReleaseBase` (variable), `meminfoPath`, `strataDiskFree` et
|
||||
`serviceActionOSFn`. Le journal couvre :
|
||||
|
||||
- **la machine** :
|
||||
- sans carte, nvidia-smi en panne ou vide, carte trop ancienne ou trop
|
||||
petite, carte d'aide trop ancienne ;
|
||||
- trois cartes de même VRAM : la plus récente devant (capacité de calcul
|
||||
arrondie au dixième) ;
|
||||
- Python absent, en panne, illisible, trop ancien, sans venv ;
|
||||
- 16, 47 ou 64 Go de RAM, meminfo sans MemAvailable ;
|
||||
- disque presque plein ou juste trop petit (marge de 5 Go), données déjà
|
||||
présentes ;
|
||||
- **les réglages** :
|
||||
- GET, corps illisible, preset vide ou avec chemin, preset absent ou
|
||||
llama.cpp ;
|
||||
- hors limites, experts inconnus ;
|
||||
- preset inactif ; preset actif (appliqué et relancé), et pendant une
|
||||
optimisation ;
|
||||
- **l'installation** :
|
||||
- GET, corps illisible, quant inconnu, optimisation en cours ;
|
||||
- machine sans carte, modèle qui ne tient pas ;
|
||||
- release introuvable, moteur altéré (empreinte) ;
|
||||
- installation complète, puis réinstallation (sources déjà là et
|
||||
décompressées, moteur téléchargé périmé retéléchargé) ;
|
||||
- installeur en échec, installeur sans configuration ;
|
||||
- **les modèles installés** :
|
||||
- détails de lancement du modèle actif et de modèles inactifs (experts
|
||||
sur disque ou en RAM, vision coupée, sans carte d'aide) ;
|
||||
- mode du dernier lancement ;
|
||||
- `experts.bin` avec le moteur du paquet ou d'un autre paquet ;
|
||||
- RAM juste sous ou au-dessus du seuil du mode hors RAM, mode hors RAM
|
||||
coupé dans le preset ;
|
||||
- configuration de lancement disparue ou illisible ;
|
||||
- **après chaque appel** : les actions du service, les clés de la
|
||||
configuration et l'arborescence de `presets/` et `strata/` (sans le
|
||||
contenu des archives).
|
||||
|
||||
Autres tests :
|
||||
|
||||
- les tests du Go (`backend_strata_test.go`), portés (`strata/tests.rs`) :
|
||||
configuration de lancement, placement des experts, recommandation,
|
||||
réglages, noms ;
|
||||
- la RAM du moteur en marche, le découpage en lignes et les nombres écrits
|
||||
à la Go ;
|
||||
- le Go (`TestFacadeStrata`) : les trois routes partent à la façade, et
|
||||
elles seules.
|
||||
|
||||
## Mutations
|
||||
|
||||
71 mutations du port Rust, toutes tuées (les dix survivantes du premier
|
||||
passage l'ont été par les étapes et les tests ajoutés ci-dessus).
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs réseau (release injoignable) gardent le texte de `reqwest`.
|
||||
- Une copie du moteur qui échoue (`prebuilt/`) ne donne pas exactement le
|
||||
message du Go, qui ouvre la source puis la destination.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Lot 26 — Tâches planifiées tenues par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Une tâche planifiée tourne seule, sans utilisateur en face. Il en existe
|
||||
deux sortes :
|
||||
|
||||
- **une tâche IA** : une consigne, exécutée par l'agent dans un fil
|
||||
éphémère ;
|
||||
- **une tâche « script seul »** : un script du dossier protégé
|
||||
(`$LOKI_HOME/scripts`), lancé sans modèle. Sa sortie devient le
|
||||
compte-rendu.
|
||||
|
||||
Le Go les tient dans plusieurs fichiers :
|
||||
|
||||
- `tasks.go` : la base (`bkTasks`) et l'interrupteur maître ;
|
||||
- `tasks_cron.go` : les fréquences ;
|
||||
- `tasks_sched.go` : le planificateur, un tic par minute et une tâche due
|
||||
par tic ;
|
||||
- `tasks_run.go` et `tasks_script.go` : l'exécution ;
|
||||
- `web_tasks.go` : sept routes.
|
||||
|
||||
Les fréquences prennent quatre formes :
|
||||
|
||||
- `@every 2h` : intervalle glissant d'au moins une minute ;
|
||||
- `@every 3d@9:30` : tous les N jours, à une heure murale ;
|
||||
- `@once 2026-10-01 16:54` : une seule fois ;
|
||||
- une expression cron à cinq champs.
|
||||
|
||||
L'heure murale est celle du fuseau de la tâche. Sans fuseau, ou avec un
|
||||
fuseau inconnu, c'est l'heure locale du serveur.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_TASKS=rust` (expérimental), la façade (`rust/loki-rs/src/tasks`)
|
||||
sert les routes `/api/tasks*` et fait tourner le planificateur, que le Go ne
|
||||
démarre plus. Elle exécute elle-même les tâches script :
|
||||
|
||||
- le shell de l'agent (`run_shell`), dans le dossier de la discussion
|
||||
ouverte ;
|
||||
- une limite de 10 min ;
|
||||
- un arrêt par `/api/tasks/stop`.
|
||||
|
||||
Une tâche IA passe par le verrou de génération et les outils de l'agent, qui
|
||||
restent au Go. La façade la lui fait exécuter par `/internal/v1/tasks/run`,
|
||||
que le Go traite comme son propre planificateur l'aurait fait :
|
||||
|
||||
- preset épinglé ;
|
||||
- projet forcé ;
|
||||
- `RunAutonomous` ;
|
||||
- état persisté ;
|
||||
- notification.
|
||||
|
||||
Moteur occupé ou modèle pas prêt : la tâche ne tourne pas, son état ne bouge
|
||||
pas, et le tic suivant la réessaie. Le Go rend la main à la fin du passage.
|
||||
|
||||
Les notifications des tâches script partent par la façade si elle tient les
|
||||
notifications, sinon par le Go (`/internal/v1/push/send`).
|
||||
|
||||
Les fréquences demandent l'heure murale d'un fuseau IANA. `src/tz.rs` porte
|
||||
ce que le Go fait de la zoneinfo du système :
|
||||
|
||||
- lecture des TZif v1 et v2, et de la règle POSIX qui prolonge les
|
||||
transitions ;
|
||||
- `time.Local` (`$TZ`, `/etc/localtime`) ;
|
||||
- `time.Date`, avec la normalisation des débordements ;
|
||||
- les heures qui sautent ou qui se répètent, résolues comme le Go.
|
||||
|
||||
`tasks/cron.rs` porte `time.ParseDuration` du Go, messages d'erreur compris.
|
||||
|
||||
Côté Go :
|
||||
|
||||
- `facade_tasks.go` : le relais des routes reçues par le tunnel et les deux
|
||||
routes internes ;
|
||||
- `web_server.go` : le planificateur n'est pas démarré ;
|
||||
- `tasksNow` : l'heure des tâches, remplaçable en test.
|
||||
|
||||
## Correction de la façade
|
||||
|
||||
Les appels de la façade à l'API interne du Go étaient tous bornés à 10 s. Un
|
||||
outil exécuté par le Go (`/internal/v1/tool/exec`) qui dure plus longtemps
|
||||
était coupé :
|
||||
|
||||
- une page web lente ;
|
||||
- le navigateur ;
|
||||
- un serveur MCP tenu par le Go.
|
||||
|
||||
Le résultat partait en erreur alors que l'outil continuait. Ces appels sont
|
||||
désormais bornés à 30 min. Une tâche IA exécutée par le Go est attendue sans
|
||||
autre limite que 24 h.
|
||||
|
||||
## Ce que les mutations ont corrigé dans le port
|
||||
|
||||
Le premier passage des mutations a laissé survivre des cas que le journal ne
|
||||
voyait pas. Confrontés au Go, plusieurs étaient de vrais écarts :
|
||||
|
||||
- **dates lointaines** : un instant en nanosecondes sur 64 bits s'arrête en
|
||||
2262. Le Go accepte `@once 2400-02-29 10:00`, ou un intervalle en jours
|
||||
qui mène au-delà. Les instants des fréquences passent sur 128 bits ;
|
||||
- **intervalle en jours démesuré** : `@every 200000d` déborde dans le Go
|
||||
(`time.Duration`), qui rend une date passée. Le port déborde de même ;
|
||||
- **l'heure qui saute** : `@every 1d@02:30` à Paris, le jour du passage à
|
||||
l'heure d'été, tombe à 3 h 30. Le Go avance ensuite de jour en jour depuis
|
||||
ce candidat (`AddDate`) et reste à 3 h 30. Le port repartait de 2 h 30 ;
|
||||
- **compte-rendu tronqué** : le Go coupe à 4000 octets, au milieu d'un
|
||||
caractère s'il le faut, et chaque octet orphelin devient U+FFFD à
|
||||
l'encodage JSON. Le port reculait à une frontière de caractère ;
|
||||
- **nom de fuseau** : le Go refuse tout nom qui contient `..`, pas seulement
|
||||
un composant `..`.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tasks_test.go` (Linux) tire du Go un journal rejoué
|
||||
par `tests/tasks.rs`. L'heure des tâches est fixée pas à pas ; les
|
||||
identifiants tirés au hasard deviennent `{{ID1}}`, `{{ID2}}`… dans l'ordre où
|
||||
ils apparaissent. Le journal couvre :
|
||||
|
||||
- **l'écran** :
|
||||
- scripts du dossier (sous-dossiers compris) ;
|
||||
- mode agent, interrupteur maître (rangé à « 0 » : pas suspendu) ;
|
||||
- projets, mémoire coupée, internet armé ;
|
||||
- la tâche en cours pendant un script ;
|
||||
- **l'enregistrement** :
|
||||
- corps illisible, sans nom, sans consigne ;
|
||||
- script absent, dossier, remontée, chemin absolu, séparateur Windows,
|
||||
caractère interdit ;
|
||||
- chaque erreur de fréquence : vide, intervalle trop court, illisible ou
|
||||
sans unité, jours ou heure invalides, cron à 4 champs, hors bornes, en
|
||||
lettres, plage inversée, pas nul, jamais satisfait, date illisible ou
|
||||
déjà passée ;
|
||||
- chaque forme de fréquence, dans plusieurs fuseaux : Paris (dont l'heure
|
||||
qui saute), New York, Calcutta, fuseau inconnu ;
|
||||
- id inconnu gardé, projet inconnu remplacé par l'actif ;
|
||||
- modification avec ou sans changement de fréquence ;
|
||||
- **la bascule, l'interrupteur maître, la suppression** ;
|
||||
- **les lancements** :
|
||||
- tâche absente ;
|
||||
- tâche IA avec moteur occupé (409) ou modèle pas prêt (503) ;
|
||||
- script en succès, en échec, arrêté à la main ;
|
||||
- script qui échoue après un succès, puis se rétablit ;
|
||||
- tâche script rangée sans script, au nom refusé, au script disparu ;
|
||||
- **le planificateur** :
|
||||
- tic suspendu, tic pendant une optimisation ;
|
||||
- une tâche IA due qui attend le modèle ;
|
||||
- une tâche due par tic, dans l'ordre des noms ;
|
||||
- la tâche « une fois » qui s'éteint ;
|
||||
- une tâche sans prochain passage (posé sans la lancer) ;
|
||||
- une fréquence devenue invalide ;
|
||||
- deux tâches de même nom (rangées par id).
|
||||
|
||||
Après chaque étape, le journal enregistre les tâches en base.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- fuseaux et fréquences confrontés au Go :
|
||||
- Paris aux deux changements d'heure, New York, Calcutta ;
|
||||
- avant la première transition, pile à une transition, la table avant la
|
||||
règle prolongée ;
|
||||
- `$TZ` (vide, « : », chemin, nom), `/etc/localtime` ;
|
||||
- noms refusés, « UTC » sans base ;
|
||||
- mois hors bornes, années bissextiles, mois de 30 jours ;
|
||||
- `ParseDuration` et ses débordements, cron (plages, pas, bornes, à 78 et
|
||||
364 jours) ;
|
||||
- le compte-rendu tronqué, les noms de script ;
|
||||
- l'amorçage au démarrage (tâches actives sans prochain passage) ;
|
||||
- la durée d'un passage, à une horloge qui avance ;
|
||||
- la tâche IA en cours vue par l'écran ;
|
||||
- la tâche IA lancée à la main part au Go (`tache_ia_executee_par_le_go`) ;
|
||||
- le planificateur tourne à son pas sans demande ;
|
||||
- sans `LOKI_TASKS`, les routes partent au Go ;
|
||||
- le Go (`TestFacadeTaches`) :
|
||||
- les sept routes partent à la façade, et elles seules ;
|
||||
- `/internal/v1/tasks/run` sur une tâche IA sans modèle (occupée, rien
|
||||
d'enregistré) et sur une tâche script ;
|
||||
- `/internal/v1/push/send`.
|
||||
|
||||
## Mutations
|
||||
|
||||
79 mutations du port Rust, dont 77 tuées. Les survivantes du premier passage
|
||||
l'ont été par les tests et les étapes ajoutés ci-dessus. Restent deux
|
||||
équivalentes :
|
||||
|
||||
- le refus d'un nom de script absolu **après** nettoyage : sous Unix, un nom
|
||||
qui ne commence pas par `/` ne le devient pas (le contrôle du Go vaut pour
|
||||
Windows) ;
|
||||
- `web_on` sans `internet_enabled` : la joignabilité refuse déjà un moteur
|
||||
Crawl4AI sans adresse, seul cas où les deux diffèrent.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Pendant une tâche IA, le Go force le projet de la tâche. Une tâche
|
||||
enregistrée sans projet à ce moment-là prend, côté façade, le projet actif
|
||||
rangé.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Lot 27 — Banc d'essai tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le banc d'essai mesure le moteur local tel que le chat s'en sert, sur
|
||||
`/v1/chat/completions`, avec le gabarit, le raisonnement et
|
||||
l'échantillonnage du preset (`llm_bench.go`, `llm_bench_job.go`). Il a deux
|
||||
modes :
|
||||
|
||||
- **rapide** : un préchauffage jeté, puis la ligne courte (~2000 jetons de
|
||||
prompt, ~300 générés) ;
|
||||
- **complet** : la ligne courte, puis une discussion à profondeur réelle —
|
||||
un prefill à froid de D jetons, et trois tours qui y ajoutent chacun ~2048
|
||||
jetons neufs. La reprise du cache est lue dans la réponse, jamais
|
||||
supposée.
|
||||
|
||||
Il tourne en tâche de fond derrière quatre routes :
|
||||
|
||||
- `POST /api/bench` lance (202 et un id) ;
|
||||
- `GET /api/bench/status` dit la phase, puis le résultat ou l'erreur ;
|
||||
- `POST /api/bench/cancel` annule ;
|
||||
- `GET /api/bench/last` rend la dernière mesure enregistrée.
|
||||
|
||||
Pendant la mesure, le bench tient le verrou de génération de la
|
||||
conversation. Un message, une tâche planifiée ou un client `/v1` reçoivent
|
||||
le refus du bench. Seule une mesure complète est enregistrée : la
|
||||
dernière, et celle du preset actif.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_BENCH=rust` (expérimental), la façade (`rust/loki-rs/src/bench`)
|
||||
sert les quatre routes et déroule le protocole contre le moteur :
|
||||
|
||||
- `bench/mod.rs` : le protocole, porté du Go à l'octet près. On y trouve :
|
||||
- les mêmes corpus : la prose du Go et l'interface web embarquée, de la
|
||||
même source ;
|
||||
- les mêmes requêtes ;
|
||||
- le même calcul de profondeur, de reprise du cache et de débit ;
|
||||
- les mêmes messages d'erreur ;
|
||||
- `bench/job.rs` : le job, les routes et l'enregistrement (`last_bench`,
|
||||
`bench_presets`, avec l'empreinte de configuration du Go).
|
||||
|
||||
Le verrou de génération reste celui de la conversation du Go. La façade le
|
||||
prend par le **bail du moteur**, avec le motif `bench` :
|
||||
|
||||
- le Go note alors `benching` et donne aux autres le refus du benchmark ;
|
||||
- changer de discussion ne rompt pas le bail ;
|
||||
- `benchRunning()` le voit aussi : les clients `/v1`, le préchauffage et
|
||||
l'optimiseur attendent ;
|
||||
- un stop du chat côté Go (signalé au renouvellement), un Reset ou un bail
|
||||
perdu annulent le bench.
|
||||
|
||||
Le Go prête aussi à la façade :
|
||||
|
||||
- **le travail annexe** (`/internal/v1/engine/sidejob`) : à la fin, le slot
|
||||
du bench est effacé si c'est sans risque, comme pour un bench du Go ;
|
||||
- **la télémétrie** (`/internal/v1/bench/perf`) : la trace du bench dans
|
||||
`perf_log`.
|
||||
|
||||
Le port, la clé de l'API locale et le second slot (`SIDE_SLOT`) viennent de
|
||||
`/internal/v1/oai/route`. Les niveaux de raisonnement appris sont ceux de la
|
||||
conversation de la façade si elle la tient, sinon ceux du bench.
|
||||
|
||||
La ligne de commande (`loki bench`) reste au Go.
|
||||
|
||||
## Correction des tests du Go
|
||||
|
||||
Trois tests du préchauffage échouaient dix minutes par jour, avant minuit :
|
||||
le préchauffage se tait alors (`prewarmNearMidnight`), et ils lisaient
|
||||
l'heure réelle. L'heure de ce garde-fou devient remplaçable (`prewarmNow`),
|
||||
et les tests la fixent à midi.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_bench_test.go` (Linux) tire du Go un journal rejoué
|
||||
par `tests/bench.rs` contre le même faux moteur (`fakeBenchEngine`, porté).
|
||||
Chaque requête reçue par le moteur est comparée ; les longs textes (corpus)
|
||||
sont réduits à leur taille et à leur empreinte. Le journal couvre :
|
||||
|
||||
- **les routes** : statut et dernière mesure sans bench, GET refusés,
|
||||
annulation sans bench ;
|
||||
- **les refus au lancement** : preset externe, optimisation en cours, modèle
|
||||
pas prêt, génération en cours, second lancement pendant un bench ;
|
||||
- **les échecs dans le job** : moteur occupé par un autre (`/slots`), preset
|
||||
Strata ;
|
||||
- **les mesures** :
|
||||
- mode rapide, puis mode complet (profondeur, trois tours, reprise du
|
||||
cache, brouillon) ;
|
||||
- contexte trop petit (profondeur sautée), contexte inconnu du moteur
|
||||
(celui du preset) ;
|
||||
- paramètres bornés, petits paramètres, corps illisible ;
|
||||
- **les échecs d'une mesure** :
|
||||
- préchauffage, ligne courte, prefill à froid ou tour en profondeur en
|
||||
erreur ;
|
||||
- réponse sans timings ;
|
||||
- **le raisonnement** : niveau refusé par le gabarit puis rejoué, puis le
|
||||
niveau appris envoyé d'emblée ;
|
||||
- **budget et annulation** : budget d'une requête dépassé (profondeur
|
||||
partielle, non enregistrée), annulation pendant la profondeur ;
|
||||
- **après chaque étape** : la dernière mesure et les mesures par preset.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- ceux du Go (`llm_bench_test.go`), portés (`bench/tests.rs`) : profondeur,
|
||||
types de cache, requête, morceaux du corpus, reprise du cache, relectures
|
||||
du disque, empreinte de l'enregistrement ;
|
||||
- la réponse du moteur (timings, cache, brouillon, nombres illisibles),
|
||||
`Duration.String` du Go, les flottants écrits à la Go ;
|
||||
- la façade : le bail « bench », le travail annexe (slot servi ou non), la
|
||||
trace, la clé de l'API locale, le second slot, le stop du chat et le bail
|
||||
perdu, les routes sans la façade ;
|
||||
- le Go (`TestFacadeBench`) :
|
||||
- les quatre routes partent à la façade, et elles seules ;
|
||||
- le bail « bench » : refus, bascule de discussion, libération,
|
||||
expiration ;
|
||||
- le travail annexe et la trace.
|
||||
|
||||
## Mutations
|
||||
|
||||
84 mutations du port Rust, dont 81 tuées. Les survivantes du premier passage
|
||||
l'ont été par les tests ajoutés :
|
||||
|
||||
- `bench/tests.rs` : la tranche pile à la fin du corpus, un tour sans
|
||||
attendu à côté d'un tour mesuré, les débits nuls rendus par le moteur
|
||||
(recalculés), le niveau de raisonnement rejoué une seule fois, le
|
||||
préchauffage et la ligne courte d'un `run` rapide (paramètres par défaut,
|
||||
64 jetons de préchauffage au moins, code ajouté juste au-delà de la
|
||||
prose), la profondeur arrêtée (contexte plein, corpus épuisé, réponse du
|
||||
tour précédent reprise, note de reprise selon le modèle hybride) ;
|
||||
- `tests/bench.rs` : un second lancement pendant la prise du bail, le preset
|
||||
relu au départ du job (devenu externe, ou optimiseur lancé entre-temps),
|
||||
la mesure enregistrée sous le preset ACTIF ;
|
||||
- le journal : un prompt juste au-delà de la prose.
|
||||
|
||||
Restent trois équivalentes :
|
||||
|
||||
- l'annulation vérifiée avant d'envoyer : déjà annulé, le `select!` rend
|
||||
l'annulation à son premier tour, avant que la requête ne parte ;
|
||||
- la progression d'un job remplacé : un seul bench à la fois, un job fini ne
|
||||
rapporte plus rien ;
|
||||
- le PID nul du moteur : `/proc/0` n'existe pas, la lecture échoue de toute
|
||||
façon.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Une erreur réseau vers le moteur garde le texte de `reqwest` après le
|
||||
préfixe du Go (`Post "url": …`).
|
||||
- Un niveau de raisonnement appris par le bench de la façade n'est pas
|
||||
transmis au chat du Go, quand le Go tient la conversation (et
|
||||
inversement) : la première requête paie le refus, puis la traduction est
|
||||
retenue.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Lot 7 — outils de l'agent exécutés par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Lot 6 : quand la façade tient la conversation (`LOKI_CHAT=rust`), la boucle du
|
||||
tour est en Rust mais chaque appel d'outil repart au Go
|
||||
(`/internal/v1/tool/exec`). Lot 7 : porter les outils eux-mêmes, en retirant
|
||||
les appels au Go un par un. Rien ne change sans `LOKI_CHAT=rust` : la boucle
|
||||
Go exécute ses outils comme avant.
|
||||
|
||||
## Étape 1 — fichiers et shell (fait)
|
||||
|
||||
Exécutés par la façade (`rust/loki-rs/src/tools/`) : `read`, `grep`, `glob`,
|
||||
`write`, `edit`, `bash`, `ask`, avec les mêmes garde-fous dans le même ordre
|
||||
(commande catastrophique, `&` final en mode Code, bac à sable du mode Code,
|
||||
dossier mémoire interdit, « lu avant d'écrire »), le même diff pour l'UI et le
|
||||
même nettoyage de la sortie du shell (`| tail -N`, ANSI, fin bornée).
|
||||
|
||||
L'état partagé reste au Go, en un seul exemplaire, derrière trois routes
|
||||
internes (`web_internal_toolstate.go`) :
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `POST /internal/v1/tool/context` | dossier de travail (`agentCwd`), dossier mémoire, cible distante |
|
||||
| `POST /internal/v1/tool/tracker` | `read` : lecture notée ; `check` : refus éventuel avant écriture |
|
||||
| `POST /internal/v1/tool/wrote` | après une écriture : note du suivi, bloc des diagnostics LSP |
|
||||
|
||||
Le suivi reste au Go parce que les sous-agents et les tâches planifiées, qui y
|
||||
tournent, le lisent et l'écrivent aussi ; les serveurs LSP y vivent.
|
||||
|
||||
Repli sur le Go : outil non porté ; `write`/`edit`/`bash` quand l'agent vise
|
||||
un poste distant (une commande catastrophique est refusée avant, ici) ;
|
||||
contexte indisponible.
|
||||
|
||||
Le jeton de l'API interne est retiré de l'environnement du shell de l'agent.
|
||||
|
||||
### Parité
|
||||
|
||||
`internal/loki/parity_tools_test.go` passe 58 appels à `execTool` sur une
|
||||
arborescence de test et range le journal (libellés, résultats, diffs,
|
||||
questions, arborescence finale) dans `rust/loki-rs/tests/parity_tools/` ;
|
||||
`rust/loki-rs/tests/tools.rs` rejoue les mêmes appels dans `exec_local` et
|
||||
retrouve le même journal. Le test Go échoue si le comportement Go change
|
||||
(`LOKI_PARITY_UPDATE=1` pour régénérer, puis le test Rust dit si le port suit).
|
||||
|
||||
### Écarts assumés
|
||||
|
||||
- Motif `grep` invalide : le message d'erreur est celui du moteur d'expressions
|
||||
de Rust (crate `regex`), pas celui de Go.
|
||||
- Classes `\w`, `\b`, `\s`, `\d` d'un motif `grep` : Unicode en Rust, ASCII en
|
||||
Go (un mot accentué y est un mot entier).
|
||||
- Octets hors UTF-8 dans un fichier lu ou cherché : remplacés par U+FFFD par
|
||||
séquence invalide (Go : par octet, à l'encodage JSON). `edit` travaille sur
|
||||
les octets et laisse intact ce qu'il ne remplace pas.
|
||||
- `mkdir` en échec : le chemin cité est le dossier parent visé, pas le
|
||||
composant qui a échoué.
|
||||
|
||||
## Étape 2 — boucle du contrat du mode Code (fait)
|
||||
|
||||
`codeVerifyLoop` porté (`rust/loki-rs/src/chat/verify.rs`) : à la fin d'un
|
||||
tour de build en mode Code, relances du builder sur les critères encore
|
||||
ouverts, passe de vérification sur un contexte isolé (`POST
|
||||
/internal/v1/turn/verifier` : prompt du rôle et faits de la machine, outils
|
||||
fermés, rôle `verifier` qui seul peut marquer `passed`), corrections, abandon
|
||||
dit dans le fil après deux corrections. Mêmes deltas que le Go (`role`,
|
||||
`verify_done`, mesures `ctx_isolated`). Les critères sont lus en base
|
||||
(`chat/crit:<id>`) ; l'outil `criteria` reste au Go. Toute compaction en mode
|
||||
Code rend leur état au résumé, comme `compactMessages`.
|
||||
|
||||
Écart assumé : l'isolation du slot du moteur après la vérification
|
||||
(`engineSideJob`) reste au Go, qui ne voit pas passer les requêtes de la
|
||||
façade.
|
||||
|
||||
## Étape 3 — git (fait)
|
||||
|
||||
`git_status`, `git_diff`, `git_clone` (`rust/loki-rs/src/tools/git.rs`) :
|
||||
dépôt deviné comme le Go (sous-dossier demandé, racine, unique sous-dossier
|
||||
dépôt), sorties mêlées et bornées, jamais d'invite, clone limité à https:// et
|
||||
git@hôte: et rangé dans le dossier de la discussion. Toujours sur cette
|
||||
machine, cible distante ou non (comme le Go). Journal de référence
|
||||
`tests/parity_tools/git.json` (`TestPariteOutilsGit`, deux dépôts, aucun appel
|
||||
réseau), rejoué par `tests/tools_git.rs`.
|
||||
|
||||
## Étapes suivantes
|
||||
|
||||
1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : le
|
||||
cache de pages et la conversion HTML → Markdown (bibliothèques Go) ne se
|
||||
reproduisent pas à l'octet ; à porter avec un journal de référence
|
||||
structurel plutôt qu'exact.
|
||||
2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) : ses travaux sont lus par
|
||||
l'optimiseur et la suppression d'une discussion, au Go.
|
||||
3. Mémoire (`mem_*`, `recall*`, `tracker`) : avec le portage du chiffrement.
|
||||
4. Tâches, navigateur (CDP), MCP, sous-agents, critères.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Lot 8 — routes des modèles tenues par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les modèles `.gguf` vivaient dans le Go :
|
||||
|
||||
- la liste du sélecteur (`handleModels`, `backend_models_refs.go`) ;
|
||||
- les dossiers de modèles (`backend_model_dirs.go`) ;
|
||||
- la suppression (`deleteModelFile`) ;
|
||||
- les téléchargements (`backend_models.go`, `backend_models_shards.go`) ;
|
||||
- le catalogue Hugging Face (`backend_hf.go`, `web_hf.go`, `backend_catalog.go`).
|
||||
|
||||
Lot 8 : la façade tient tout cela quand `LOKI_MODELS=rust` (expérimental ;
|
||||
sans la variable rien ne change, tout part au Go).
|
||||
|
||||
## Ce que sert la façade
|
||||
|
||||
| Route | Module | Rôle |
|
||||
|---|---|---|
|
||||
| `GET /api/models` | `models.rs` | `.gguf` des dossiers déclarés (tranches 2..N exclues), taille de la famille, tranches manquantes, qui les référence (presets, configuration en service) |
|
||||
| `POST /api/models/delete` | `models.rs` | refus si en service ; 409 avec la liste des presets sans `force` ; le modèle part avec toutes ses tranches, octets libérés rendus |
|
||||
| `GET\|POST /api/models/dirs` | `models.rs` | dossiers de modèles, nombre de modèles, espace libre ; ajout (dossier existant) et retrait |
|
||||
| `POST /api/models/download/probe` | `downloads.rs` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` |
|
||||
| `POST /api/models/download` | `downloads.rs` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes |
|
||||
| `GET /api/models/download/status` | `downloads.rs` | état de chaque téléchargement de ce lancement |
|
||||
| `POST /api/models/download/cancel` | `downloads.rs` | annule ; ce que le téléchargement a écrit est effacé |
|
||||
| `GET /api/hf/search` | `hf.rs` | dépôts GGUF, matériel local joint |
|
||||
| `GET /api/hf/files` | `hf.rs` | modèles, projecteurs et poids MTP d'un dépôt, verdict mémoire de chaque modèle (contexte du preset actif, projecteur du dépôt) |
|
||||
| `GET\|POST /api/hf/token` | `hf.rs` | jeton vérifié auprès de Hugging Face avant d'être enregistré, jamais rendu en clair |
|
||||
|
||||
Mêmes contrats (corps, codes, messages) et même mécanique que le Go, entre
|
||||
autres :
|
||||
|
||||
- téléchargement :
|
||||
- lien de page Hugging Face (`/blob/`) converti en lien direct ;
|
||||
- modèle en tranches rapatrié en entier ;
|
||||
- place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de
|
||||
refus sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS ;
|
||||
- plages d'octets en parallèle (`LOKI_DL_CONNS`) et reprise d'une
|
||||
connexion coupée ;
|
||||
- jeton envoyé aux seuls hôtes Hugging Face ;
|
||||
- catalogue : réponses mises en cache (60 s pour une recherche, 10 min pour un
|
||||
dépôt), cache vidé à l'enregistrement d'un jeton ;
|
||||
- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait
|
||||
plus, il effacerait un téléchargement qu'elle mène.
|
||||
|
||||
## Côté Go (`facade_models.go`)
|
||||
|
||||
Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par
|
||||
la façade : `withFacadeChat` lui remet ces routes, authentifiées par le
|
||||
jeton interne, comme celles du chat. Façade injoignable : 502 « modèles
|
||||
indisponibles : … ».
|
||||
|
||||
La façade transmet `LOKI_MODELS=rust` et son adresse au Go qu'elle lance.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Une source qui ignore les plages n'est plus lue en entier pendant la sonde.
|
||||
- Erreurs de transport (réseau, TLS) au format de reqwest.
|
||||
- Message d'un corps JSON illisible : celui de serde (l'interface n'en envoie
|
||||
jamais).
|
||||
- Hôte d'un lien collé en majuscules converti comme les autres.
|
||||
- Espace libre et mémoire vive inconnus hors Unix/Linux (la façade n'est
|
||||
livrée que pour Linux).
|
||||
- `/api/models/download/status` trié par date de début.
|
||||
- Nombres flottants du matériel écrits `24.0` (le Go écrit `24`) : même
|
||||
valeur JSON.
|
||||
|
||||
## Parité
|
||||
|
||||
Journaux tirés du Go et rejoués en Rust (`LOKI_PARITY_UPDATE=1` pour
|
||||
régénérer) :
|
||||
|
||||
- `internal/loki/parity_dl_test.go` → `tests/parity_models/download.json` →
|
||||
`tests/downloads.rs` : lien normalisé, famille de tranches,
|
||||
`Content-Range`, messages de refus, hôtes et dépôts Hugging Face, dossier
|
||||
de destination ;
|
||||
- `internal/loki/parity_models_test.go` → `tests/parity_models/models.json` →
|
||||
`tests/models.rs` : quantification déduite du nom, découpage
|
||||
d'`EXTRA_ARGS`, jeton masqué, `gated`, classement d'un dépôt, choix du
|
||||
projecteur, verdicts mémoire, puis un scénario sur disque de 20 appels
|
||||
(liste, dossiers, suppressions avec et sans `force`, modèle en service,
|
||||
tranches, chemins refusés) rejoué à l'identique, fichiers restants et
|
||||
dossiers enregistrés compris.
|
||||
|
||||
Deux mutations (`replacen` → `replace` sur `/blob/`, `--mmproj` ignoré)
|
||||
font échouer les rejeux.
|
||||
|
||||
De bout en bout :
|
||||
|
||||
- téléchargements contre un faux Hugging Face : trois connexions sur des
|
||||
plages disjointes, source sans plages, tranches dont une présente, dépôt
|
||||
verrouillé, panne pendant la deuxième tranche, annulation et 409 ;
|
||||
- catalogue contre un faux Hugging Face : recherche, cache, taille LFS,
|
||||
dépôt verrouillé ou absent, jeton refusé ou accepté ;
|
||||
- toutes les routes par la façade (clé de pilotage, remise du Go, base du Go
|
||||
écrite), et laissées au Go sans la variable.
|
||||
|
||||
## Reste
|
||||
|
||||
- Essai réel sur une installation avant d'en faire le défaut.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Lot 9 — dictée vocale tenue par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La dictée vocale vivait dans le Go :
|
||||
|
||||
- `dictate_config.go` : réglages ;
|
||||
- `dictate_models.go` : catalogue Parakeet, téléchargement et extraction ;
|
||||
- `dictate_server.go` : supervision de `sherpa-onnx-offline-websocket-server`
|
||||
et protocole WebSocket ;
|
||||
- `web_transcribe.go` : routes.
|
||||
|
||||
Lot 9 : la façade la tient quand `LOKI_DICTATE=rust` (expérimental ; sans la
|
||||
variable rien ne change).
|
||||
|
||||
## Ce que sert la façade (`rust/loki-rs/src/dictate.rs`)
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `POST /api/transcribe` | un WAV (≥ 1000 octets, ≤ 32 Mio) entre, le texte sort ; modèle absent : 503 `{downloading, pct}` et téléchargement lancé (relancé après un échec, qui est dit) |
|
||||
| `GET\|POST /api/dictate/config` | modèle (validé contre le catalogue) et réactivité ; enregistrer arrête le serveur, qui repart au prochain mot avec le nouveau modèle |
|
||||
| `GET /api/dictate/models` | catalogue trié par taille, présence des quatre fichiers |
|
||||
| `GET\|POST /api/dictate/models/download` | un téléchargement à la fois, progression sur les octets reçus |
|
||||
| `GET /api/dictate/state` | serveur allumé (et depuis quand), modèle, présence, téléchargement |
|
||||
| `POST /internal/facade/v1/dictate/stop` | (le Go, jeton interne) arrêt sans attendre derrière un démarrage |
|
||||
|
||||
Même mécanique que le Go :
|
||||
|
||||
**Archive `.tar.bz2`**
|
||||
- Décompressée au fil du téléchargement, dans un dossier `.part` renommé à la fin.
|
||||
- Entrées absolues ou qui remontent refusées, liens ignorés.
|
||||
|
||||
**Serveur de transcription**
|
||||
- Lancé à la demande sur un port libre de la boucle locale, GPU masqués, sans
|
||||
le jeton interne dans son environnement.
|
||||
- Prêt dès que sa socket répond.
|
||||
- Une mort au démarrage est rapportée avec sa cause et la dernière ligne du
|
||||
journal ; un processus qui ne répond pas fait échouer le démarrage au bout
|
||||
de 2 minutes.
|
||||
- Éteint après dix minutes d'inactivité.
|
||||
- `shutdown_fast` tue le processus qui démarre au lieu d'attendre le verrou.
|
||||
|
||||
**Protocole**
|
||||
- En-tête de 8 octets (fréquence, nombre d'octets), échantillons float32 en
|
||||
trames de 10 Kio, une trame texte en retour.
|
||||
- Annotations (`[musique]`, `(rires)`) écartées.
|
||||
|
||||
## Côté Go (`facade_dictate.go`)
|
||||
|
||||
- `withFacadeChat` remet les routes du tunnel et de la redistribution E2E à la
|
||||
façade. Si elle ne répond pas : 502 « dictée indisponible : … ».
|
||||
- `asrShutdownVite` passe par la façade quand elle tient la dictée : son
|
||||
serveur n'est pas un enfant de ce process. Cela couvre la libération de la
|
||||
VRAM et l'arrêt du moteur principal par l'optimiseur.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Messages d'erreur de transport (WebSocket, HTTP) et de décompression au
|
||||
format des crates Rust.
|
||||
- Fin de connexion sans réponse : « lecture du résultat : connexion fermée ».
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_dictate_test.go` tire du Go ce journal :
|
||||
|
||||
- treize WAV : mono, bloc `LIST` intercalé, stéréo moyenné, flottant 32 bits,
|
||||
données tronquées, impaires, vides, et les refus ;
|
||||
- les chemins d'archive ;
|
||||
- les marqueurs ;
|
||||
- les réglages par défaut ;
|
||||
- les arguments du serveur ;
|
||||
- les adresses des modèles ;
|
||||
- la présence ;
|
||||
- treize appels de route.
|
||||
|
||||
Le tout est rangé dans `tests/parity_dictate/dictate.json` et rejoué par
|
||||
`tests/dictate.rs`, les routes par la façade. Une mutation (premier canal au
|
||||
lieu de la moyenne) fait échouer le rejeu.
|
||||
|
||||
`tests/dictate.rs` couvre aussi :
|
||||
|
||||
- le protocole contre un faux serveur WebSocket (en-tête, découpage
|
||||
10240 + 10240 + 3528, marqueurs, réponse illisible, serveur injoignable) ;
|
||||
- l'extraction (lien ignoré, entrée qui remonte refusée sans rien laisser,
|
||||
archive corrompue) ;
|
||||
- le téléchargement depuis un faux serveur (100 %, HTTP 404, modèle inconnu,
|
||||
un seul à la fois) ;
|
||||
- la supervision d'un faux sherpa-onnx (script Python qui parle le
|
||||
protocole) :
|
||||
- transcription de bout en bout par la façade ;
|
||||
- même serveur réutilisé ;
|
||||
- arrêt demandé par le Go, et refusé sans le jeton ;
|
||||
- réglage changé ;
|
||||
- les démarrages ratés : sortie en code 3 avec sa dernière ligne, serveur
|
||||
muet, arrêt « vite » qui tue le démarrage, binaire introuvable, modèle
|
||||
absent.
|
||||
@@ -0,0 +1,263 @@
|
||||
# Lot 28 — Optimiseur tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
L'optimiseur sans perte (`backend_tune*.go`) cherche des réglages du moteur
|
||||
plus rapides **sans rien changer à ce que le modèle calcule** : même
|
||||
contexte, mêmes slots, mêmes types de cache, même échantillonnage. Il
|
||||
procède ainsi :
|
||||
|
||||
1. Il arrête le vrai moteur.
|
||||
2. Il lance chaque essai sur une COPIE de la configuration :
|
||||
- posée dans `LOKI_HOME/tune/run/<essai>/config.env`, jamais
|
||||
`config.env` ;
|
||||
- composée par « loki serve » à blanc ;
|
||||
- puis lancée sur 127.0.0.1 et un port libre.
|
||||
3. Il mesure chaque essai par le bench complet, à une profondeur fixe.
|
||||
4. Il garde ce qui gagne au-delà du bruit.
|
||||
5. Il relance le vrai moteur.
|
||||
|
||||
Rien n'est écrit dans un preset sans un geste de l'utilisateur : dans une
|
||||
copie, ou dans le preset lui-même — moteur redémarré, sondé, et ancienne
|
||||
version rétablie si la sonde échoue.
|
||||
|
||||
Cinq routes :
|
||||
|
||||
- `POST /api/tune` lance (202) ;
|
||||
- `GET /api/tune/status` dit la phase, les essais, l'ETA, puis le résultat ;
|
||||
- `POST /api/tune/cancel` annule ;
|
||||
- `POST /api/tune/apply` écrit le résultat, sur un clic (`copy` ou
|
||||
`preset`) ;
|
||||
- `GET /api/tune/last` rend le dernier résultat d'un preset, et si le preset
|
||||
ou le moteur ont changé depuis.
|
||||
|
||||
Le moteur, pendant une optimisation, appartient aux essais. Le verrou est un
|
||||
**fichier** de `LOKI_HOME` (`tune.lock`), créé de façon exclusive, que tout
|
||||
le monde lit :
|
||||
|
||||
- le Go, dans tous ses refus (`tuneGuard`) ;
|
||||
- « loki tune » en ligne de commande ;
|
||||
- désormais la façade.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_TUNE=rust` (expérimental, Unix seulement), la façade
|
||||
(`rust/loki-rs/src/tune`) sert les cinq routes et déroule l'optimisation :
|
||||
|
||||
- `tune/core.rs` : la partie pure, portée à l'octet près :
|
||||
- découpe et réécriture d'`EXTRA_ARGS` ;
|
||||
- réglages par clé ou par drapeau ;
|
||||
- essais de chaque étape selon la machine et les cases cochées ;
|
||||
- garde-fous : cœur de la ligne, dénature, clés permises ;
|
||||
- diff et réécriture du preset ;
|
||||
- journal d'un essai ;
|
||||
- score et départage ;
|
||||
- `tune/lock.rs` : le verrou, **au format du Go** :
|
||||
- contenu du fichier, clé de prise, binaires comparés par leur nom ;
|
||||
- identité d'un processus comme `procIdentity` l'écrit : champ 22 de
|
||||
`/proc/<pid>/stat`, préfixé de l'identifiant du démarrage ; `ps` ailleurs ;
|
||||
- écriture d'un bloc ;
|
||||
- rendu neutre avant retrait ;
|
||||
- ménage par déplacement-relecture ;
|
||||
- `tune/job.rs` : les routes, le job, les essais et l'application.
|
||||
|
||||
**La reprise reste au Go.** Sa reprise (`tuneRecoverWatch`, toutes les
|
||||
30 s) lit le verrou de la façade et la reconnaît vivante, par la même
|
||||
identité. Si la façade meurt en plein essai, le Go écarte son verrou comme
|
||||
celui d'un « loki tune » tué :
|
||||
|
||||
- l'essai orphelin est arrêté (identité complète vérifiée) ;
|
||||
- une application interrompue est défaite ;
|
||||
- le moteur est relancé s'il tournait.
|
||||
|
||||
La façade fait le même ménage quand elle trouve un verrou périmé en prenant
|
||||
le sien.
|
||||
|
||||
**Les essais restent des « loki serve » du Go**, lancés par la façade avec
|
||||
`LOKI_TUNE_TRIAL` (et `LOKI_TUNE_DRYRUN` à blanc), dans leur propre groupe de
|
||||
processus. Le groupe est arrêté en sortie, quoi qu'il arrive. L'essai est
|
||||
noté dans le verrou avant de charger quoi que ce soit.
|
||||
|
||||
Le verrou de génération reste celui de la conversation du Go. La façade le
|
||||
prend par le **bail du moteur**, avec le motif `tune` :
|
||||
|
||||
- le Go note alors `benching` et donne aux autres le refus de l'optimiseur ;
|
||||
- changer de discussion ne rompt pas le bail ;
|
||||
- un stop du chat, un Reset ou un bail perdu annulent l'optimisation.
|
||||
|
||||
Le Go prête à la façade ce qu'il est seul à voir
|
||||
(`/internal/v1/tune/context`) :
|
||||
|
||||
- **le refus du processus web** : un benchmark (le sien ou celui de la
|
||||
façade), une génération, des jobs d'arrière-plan du mode code ;
|
||||
- **l'état du vrai moteur** (`engineNeedsStop`) ;
|
||||
- **la télémétrie**, d'où viennent les poids d'un tour type.
|
||||
|
||||
Il prête aussi le démarrage et l'arrêt du moteur **sans le refus de
|
||||
l'optimiseur** (`/internal/v1/service/action`, `os`), puisque c'est la
|
||||
façade qui tient le verrou. Si la façade pilote elle-même le moteur
|
||||
(`LOKI_ENGINE=rust`), elle passe par son superviseur.
|
||||
|
||||
Le preset est réécrit et appliqué par les fonctions des presets
|
||||
(`save_applying`, mode `apply` : la configuration, sans redémarrage). Le
|
||||
redémarrage, la sonde et le retour arrière sont ceux de l'optimiseur.
|
||||
|
||||
La ligne de commande (`loki tune`) reste au Go.
|
||||
|
||||
Hors Unix, la façade ne tient pas l'optimiseur. Elle passe alors
|
||||
`LOKI_TUNE` vide au Go, qui le garde et ne relaie rien.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tune_test.go` tire du Go un journal
|
||||
(`tests/parity_tune/core.json`) rejoué par `tests/tune_core.rs`. Il couvre :
|
||||
|
||||
- toute la partie pure ;
|
||||
- le **verrou** : contenu du fichier octet pour octet, clé de prise,
|
||||
comparaison des binaires ;
|
||||
- le **résultat** tel qu'enregistré et montré (listes vides écrites `null`
|
||||
comme une tranche nil du Go) ;
|
||||
- la vérification **après application** (la configuration est-elle l'essai
|
||||
retenu ?).
|
||||
|
||||
`tests/tune.rs` déroule de vraies optimisations, essais compris : le binaire
|
||||
de test joue « loki serve ».
|
||||
|
||||
- À blanc, il écrit la ligne de commande de l'essai.
|
||||
- Pour de vrai, il sert un faux llama-server dont le prefill est d'autant
|
||||
plus rapide que le micro-lot est petit.
|
||||
|
||||
Les tests couvrent :
|
||||
|
||||
- **une optimisation entière** :
|
||||
- bail « tune » et verrou au nom de la façade ;
|
||||
- vrai moteur arrêté puis relancé sans le refus de l'optimiseur, dictée
|
||||
coupée ;
|
||||
- quatre essais composés, lancés, mesurés, l'un retenu ;
|
||||
- copies de configuration sur une adresse privée ;
|
||||
- résultat enregistré par preset et rendu par `/api/tune/last` ;
|
||||
- **l'application** :
|
||||
- dans une copie (rien d'autre ne bouge) ;
|
||||
- dans le preset (réécrit, appliqué, moteur redémarré et sondé) ;
|
||||
- refusée sur un preset changé depuis la mesure ;
|
||||
- **une sonde qui échoue** (contexte perdu) : preset et configuration
|
||||
rétablis, moteur redémarré dessus, sauvegarde faite avant d'écrire ;
|
||||
- **les essais refusés** : à la composition, au chargement (mémoire
|
||||
insuffisante) ; la référence qui ne se compose pas ; la relance qui
|
||||
échoue, dite dans l'erreur ;
|
||||
- **l'annulation**, par la route et par le stop du chat ;
|
||||
- **les refus d'entrée**, avec les messages du Go :
|
||||
- preset externe, Strata, aucun preset actif ;
|
||||
- jobs du mode code, moteur occupé, génération en cours ;
|
||||
- optimisation d'un autre processus (et `elsewhere` dans le statut) ;
|
||||
- **un verrou périmé** :
|
||||
- l'essai orphelin arrêté seulement si son identité concorde ;
|
||||
- une application interrompue défaite ;
|
||||
- les routes sans la façade.
|
||||
|
||||
Les tests du verrou du Go sont portés (`tune/tests.rs`) :
|
||||
|
||||
- prise, refus, essai noté, rendu ;
|
||||
- propriétaire mort ;
|
||||
- verrou à notre PID qui n'est pas l'un des nôtres ;
|
||||
- illisible neuf ou vieux ;
|
||||
- ménage jamais sur un verrou vivant ;
|
||||
- binaire remplacé ;
|
||||
- identité d'un processus ;
|
||||
- arrêt du groupe.
|
||||
|
||||
Côté Go, `TestFacadeTune` vérifie :
|
||||
|
||||
- les cinq routes partent à la façade, et elles seules ;
|
||||
- le bail « tune » : refus, bascule de discussion, libération ;
|
||||
- le contexte : au repos, pendant l'optimisation, pendant un bench ;
|
||||
- le démarrage et l'arrêt sans le refus de l'optimiseur.
|
||||
|
||||
## Mutations
|
||||
|
||||
105 mutations du port Rust (verrou et job). Le premier passage en a tué 39.
|
||||
Les survivantes l'ont été ensuite par les tests ajoutés :
|
||||
|
||||
- `tune/tests.rs` :
|
||||
- un verrou sans PID ;
|
||||
- l'identité d'un processus (démarrage noté, binaire admis) ;
|
||||
- l'application planifiée : clé sur option refusée sans l'option, valeur
|
||||
vide qui retire la clé ;
|
||||
- `tune/job.rs` : l'ETA du suivi, l'arrondi à la minute, le numéro de build ;
|
||||
- `tests/tune.rs` :
|
||||
- **une optimisation entière** :
|
||||
- l'essai noté dans le verrou pendant la mesure ;
|
||||
- l'application refusée pendant la mesure ;
|
||||
- la progression, les confirmations, le gain ;
|
||||
- aucun changement de moteur signalé quand son build est inconnu ;
|
||||
- la cible inconnue ;
|
||||
- **l'application gardée** :
|
||||
- preset qui n'est plus celui en service, API externe ;
|
||||
- clé changée ailleurs pendant l'application (défaite) ;
|
||||
- sauvegarde notée dans le verrou avant d'écrire ;
|
||||
- **les essais** :
|
||||
- écartés (ligne dénaturée, doublon, contexte perdu) ;
|
||||
- muets (délai de chargement) ;
|
||||
- **la mesure** :
|
||||
- VRAM mesurée par un faux nvidia-smi ;
|
||||
- budget atteint, profondeur commune ;
|
||||
- notes et poids tirés de la télémétrie ;
|
||||
- écarts de mesure (sous le seuil, nul, decode plus lent) ;
|
||||
- **le moteur** :
|
||||
- `/props` sans `default_generation_settings` ;
|
||||
- SIDE_SLOT sur un seul slot ;
|
||||
- threads comptés sur les cœurs de la sonde ;
|
||||
- piloté par la façade (LOKI_ENGINE=rust) ;
|
||||
- **les refus** :
|
||||
- un second lancement pendant la prise du bail ;
|
||||
- une application refusée sans journal ;
|
||||
- hors phase d'application, un verrou périmé n'est pas défait.
|
||||
|
||||
Une mutation du premier passage ne changeait rien (`plan_apply(…).and(Ok(()))`
|
||||
garde l'erreur) : elle est remplacée par une qui retire vraiment le contrôle,
|
||||
et tuée.
|
||||
|
||||
Restent vingt équivalentes, ou hors de portée d'un test :
|
||||
|
||||
- **le verrou** :
|
||||
- le fichier retiré quand son écriture échoue après sa création (disque
|
||||
plein), que les tests ne provoquent pas ;
|
||||
- deux re-vérifications du ménage, contre un propriétaire qui revient
|
||||
entre deux lectures : courses seulement ;
|
||||
- la réécriture neutre (essai, moteur actif, sauvegarde effacés) avant le
|
||||
retrait du verrou : sous Unix le retrait suit aussitôt ; elle ne compte
|
||||
que si le retrait échoue ;
|
||||
- **le suivi** :
|
||||
- le résultat montré hors mesure : il est effacé au départ d'une mesure ;
|
||||
- l'annulation n'est posée que pendant une mesure ;
|
||||
- la borne de 6 h du budget, la durée d'un doublon, les étapes restantes :
|
||||
l'estimation seulement ;
|
||||
- l'annulation vérifiée entre deux essais et à la fin : l'essai en cours
|
||||
l'a déjà dite (course) ;
|
||||
- **la mesure** :
|
||||
- VRAM nulle et nvidia-smi absent vont ensemble ;
|
||||
- les candidats générés passent toujours `cfg_check` (garde contre une
|
||||
génération fautive) ;
|
||||
- la fin d'un essai constatée avant d'attendre : un réveil perdu, quand
|
||||
l'essai meurt entre deux attentes (course) ;
|
||||
- l'essai effacé du verrou après son arrêt : le suivant le remplace, ou le
|
||||
verrou est rendu ; laissé, un Loki redémarré vérifie son identité avant
|
||||
tout arrêt ;
|
||||
- **l'application** :
|
||||
- une valeur vide gardée dans la configuration gagnante : `cfg_check` et la
|
||||
réécriture la traitent en clé absente ;
|
||||
- la configuration active comparée à celle de la mesure : les empreintes
|
||||
du preset et de la configuration sont prises ensemble, sur le preset dont
|
||||
l'empreinte est celle de la configuration ; les deux contrôles qui
|
||||
précèdent (preset inchangé, preset en service) l'impliquent ;
|
||||
- le preset « non appliqué » : la configuration a changé entre le contrôle
|
||||
et l'écriture (course) ; le contrôle suivant défait aussi, avec un autre
|
||||
message.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Une sortie à blanc qui échoue est lue dans un fichier où vont les deux
|
||||
sorties (comme `CombinedOutput`), mais une erreur de lecture de
|
||||
`launch.json` ou d'une requête garde le texte de Rust.
|
||||
- Un niveau de raisonnement appris pendant les essais n'est pas transmis au
|
||||
chat du Go quand le Go tient la conversation.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 29 — Télémétrie des complétions de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le Go range chaque complétion dans un anneau en mémoire (`perf_log.go`).
|
||||
Pour chacune, il note :
|
||||
|
||||
- combien du prompt venait du cache, combien a été recalculé ;
|
||||
- à quelle vitesse ;
|
||||
- ce que le décodage spéculatif a gardé.
|
||||
|
||||
Il expose des agrégats sur `/api/perf/summary`. Il calcule aussi la **perte
|
||||
de cache** d'une complétion : ce que le cache aurait dû reprendre de la
|
||||
précédente complétion de la même discussion, et n'a pas repris. Il nomme ce
|
||||
qui s'est intercalé entre les deux, et signale la perte à l'interface
|
||||
au-delà d'un seuil tiré du preset.
|
||||
|
||||
Quand la façade tient la conversation (`LOKI_CHAT=rust`), ses complétions
|
||||
n'apparaissaient nulle part. Le résumé des performances ignorait les tours ;
|
||||
l'optimiseur tirait ses poids d'une télémétrie vide ; l'interface ne
|
||||
recevait jamais la perte de cache.
|
||||
|
||||
## Le lot
|
||||
|
||||
Les complétions de la façade rejoignent l'anneau du Go, par un seul point
|
||||
d'entrée, `/internal/v1/turn/perf`. Il est arrivé sur `main` en même temps que
|
||||
ce lot (« Conversation tenue par Rust : télémétrie et requêtes en vol comme
|
||||
le Go »), et la fusion n'en garde qu'un.
|
||||
|
||||
- `TurnHost::perf_record` range une complétion. La boucle du tour l'appelle :
|
||||
- après chaque complétion lue, complète ou non, numérotée dans le tour
|
||||
(`perfIter`) ;
|
||||
- avec les empreintes seulement si elle est complète (`perf_prefix`,
|
||||
FNV-1a cumulé des messages envoyés) ;
|
||||
- pour une complétion coupée par un stop, rangée incomplète.
|
||||
|
||||
La dernière statistique d'une complétion complète porte ce que le Go en
|
||||
dit : la nature, la perte de cache, ce qui s'est intercalé et l'alerte.
|
||||
- La nature, posée par l'hôte :
|
||||
- `main` pour un tour et pour les corrections du builder du mode Code ;
|
||||
- `verify` pour la passe de vérification isolée ;
|
||||
- `compact` pour un résumé de compaction, rangé dès que sa réponse se lit,
|
||||
sans empreintes.
|
||||
- `chat/perf.rs` porte ce que la façade relève elle-même : la réponse entière
|
||||
d'un résumé (`perfRecFromWire`, sans échec possible, avec les mêmes
|
||||
tolérances que `perfNum`), traduite en statistiques d'une complétion.
|
||||
- Le Go range le relevé comme les siens (`perfRecFromStats`) : numéro,
|
||||
instant, perte de cache, ce qui s'est intercalé. Il calcule l'alerte au
|
||||
seuil du preset actif (`perfLostAlertAt`), jamais pour un preset externe.
|
||||
- La sonde du gabarit n'en part pas : la façade la tient (lot 23) et la lance
|
||||
elle-même après une complétion complète de forme normale. Le Go lui demande
|
||||
son verdict (`facadeTplCaps`).
|
||||
|
||||
`/api/perf/summary` reste au Go, qui tient l'anneau.
|
||||
|
||||
## Tests
|
||||
|
||||
- `chat/perf.rs` :
|
||||
- relevé d'une réponse entière : nombres en chaîne, valeurs hors bornes,
|
||||
octet parasite, cache d'OpenAI, timings d'un autre type ;
|
||||
- empreintes cumulées, FNV-1a de référence.
|
||||
- La boucle du tour :
|
||||
- deux complétions numérotées, totaux, empreintes qui se prolongent ;
|
||||
- dernière statistique avec la nature et la perte vues par le Go ;
|
||||
- complétion coupée rangée incomplète, sans statistique finale.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- complétions d'un tour rangées par le Go, rattachées à la discussion ;
|
||||
- résumés de compaction (rangés si la réponse se lit, pas sinon).
|
||||
- Le Go (`web_internal_turnperf_test.go`) :
|
||||
- complétion rangée dans l'anneau ;
|
||||
- perte et ce qui s'est intercalé, alerte ;
|
||||
- preset externe sans alerte, complétion incomplète sans empreintes ;
|
||||
- nature d'un résumé gardée, un tour par défaut ;
|
||||
- corps illisible, GET refusé.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les empreintes sont calculées sur le JSON des messages tel que Rust
|
||||
l'écrit (sans l'échappement HTML du Go). Elles ne se comparent qu'entre
|
||||
complétions de la même discussion et de la même nature, toutes produites
|
||||
par la façade.
|
||||
- Une complétion coupée par un stop est rangée sans ses compteurs partiels.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Lot 30 — Motifs de relance personnalisés tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Quand le modèle écrit un appel d'outil en toutes lettres au lieu de
|
||||
l'émettre par le protocole, Loki le reconnaît et relance le tour avec une
|
||||
consigne corrective. Les motifs embarqués couvrent les formats connus. Un
|
||||
expert peut les remplacer par la clé d'état `retry_patterns` :
|
||||
|
||||
- c'est un tableau JSON de regex du Go, chacune compilée en `(?mi)` ;
|
||||
- une regex illisible est sautée ;
|
||||
- une surcharge illisible ou vide laisse les motifs embarqués.
|
||||
|
||||
Jusqu'ici, une surcharge posée gardait la conversation au Go
|
||||
(`facadeChatBlocker`), même avec `LOKI_CHAT=rust`.
|
||||
|
||||
## Le lot
|
||||
|
||||
La façade lit la surcharge au démarrage de sa conversation
|
||||
(`chat::generate::retry_patterns`), comme le Go la compile une fois par
|
||||
processus. Le Go ne garde plus la conversation pour cette clé.
|
||||
|
||||
Les regex sont celles du Go (paquet `regexp`), pas celles de `regex`.
|
||||
`chat::stream::go_regex` les réécrit pour garder le sens du Go :
|
||||
|
||||
- les classes Perl et les limites de mot du Go sont ASCII ; celles de
|
||||
`regex` sont Unicode :
|
||||
- hors d'une classe : `\w`, `\W`, `\d`, `\D`, `\s`, `\S` deviennent des
|
||||
classes ASCII ;
|
||||
- dans une classe : leur équivalent dans la classe ;
|
||||
- `\b`, `\B` deviennent `(?-u:\b)`, `(?-u:\B)` ;
|
||||
- `\Q…\E` (texte littéral) devient le texte échappé ; le Go le refuse dans
|
||||
une classe, la traduction aussi ;
|
||||
- dans une classe, `[`, `&&`, `~~` sont littéraux pour le Go, et `--` est un
|
||||
intervalle : ils sont échappés ;
|
||||
- l'octal du Go (`\101`) est permis (`octal(true)`). Une référence arrière
|
||||
(`\1`), que le Go refuse, est refusée.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_retry_test.go` compile côté Go une soixantaine de
|
||||
regex (dont celles qu'il refuse). Il note leur première correspondance sur
|
||||
onze textes : accents, chiffres non ASCII, espaces insécables, crochets,
|
||||
majuscules accentuées. Le journal (`tests/parity_retry/patterns.json`) est
|
||||
rejoué par `tests/retry.rs` : même compilation, même correspondance, même
|
||||
position.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- la lecture de la surcharge : absente, `null`, vide, mal typée, suivie de
|
||||
texte ;
|
||||
- les regex sautées, les correspondances ;
|
||||
- la surcharge lue au démarrage de la conversation de la façade ;
|
||||
- côté Go, la clé ne garde plus la conversation.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Lot 31 — Slots du moteur pour le tour tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Trois clés opt-in du moteur local gardaient la conversation au Go
|
||||
(`facadeChatBlocker`), faute d'être honorées par la façade :
|
||||
|
||||
- `SIDE_SLOT` (`llm_sideslot.go`) : un second slot pour les travaux annexes.
|
||||
Chaque requête de Loki porte son `id_slot` : 0 pour le fil de la
|
||||
discussion (étape d'un tour, résumé en continuation), 1 pour le reste
|
||||
(vérification, résumé sur transcription). Un cache KV à court de place en
|
||||
plein calcul est rejoué tel quel, sans compaction : c'est la faute du
|
||||
voisin, pas un prompt trop long.
|
||||
- `SLOT_PERSIST` (`llm_slotpersist.go`) : l'état du slot de la discussion est
|
||||
gardé sur disque à la bascule de preset, et rechargé avant la première
|
||||
requête de la discussion au retour.
|
||||
|
||||
Et l'isolation des travaux annexes (`engineSideJob`, `CACHE_ISOLATE`) : après
|
||||
la passe de vérification du mode Code, le Go efface le slot local s'il a
|
||||
servi. La façade la sautait (écart du lot 7), le Go ne voyant pas passer ses
|
||||
requêtes.
|
||||
|
||||
Depuis la fusion de `main`, le Go voit passer chaque requête de la façade au
|
||||
moteur local (tickets « en vol », `/internal/v1/oai/begin`, `served`, `end`).
|
||||
La machinerie peut donc rester au Go ; il suffit que la façade la consulte.
|
||||
|
||||
## Le lot
|
||||
|
||||
### Go
|
||||
|
||||
- `/internal/v1/turn/slot` (`facade_slots.go`) :
|
||||
- entrée : `{"conv":"…","main":true}` ;
|
||||
- sortie : `{"slot":0|1|-1}`, la valeur d'`engineSlotFor` ;
|
||||
- avec `main` et `conv`, recharge d'abord l'état gardé de la discussion
|
||||
(`slotPersistRestore`), comme la boucle Go avant une requête d'un tour.
|
||||
- La sauvegarde à la bascule ne change pas. Le slot porte la discussion dès
|
||||
que le moteur accepte une étape de son tour : `/internal/v1/oai/served`
|
||||
appelle `engineMarkMain`.
|
||||
- `SIDE_SLOT` et `SLOT_PERSIST` ne gardent plus la conversation.
|
||||
|
||||
### Rust
|
||||
|
||||
- `TurnHost::engine_slot`, appelé avant chaque requête. La boucle pose
|
||||
l'`id_slot` rendu dans le corps.
|
||||
- Le rejeu « cache KV plein » (`sideSlotPoolError`) n'a lieu qu'avec un slot
|
||||
en service et un moteur local :
|
||||
- deux fois au plus, après l'attente des reprises réseau ;
|
||||
- puis l'erreur telle quelle ;
|
||||
- ni compaction ni réduction.
|
||||
- `GoTurnHost` :
|
||||
- moteur externe : aucun slot ;
|
||||
- étape d'un tour : `main` avec la discussion (donc le rechargement
|
||||
`SLOT_PERSIST`) ;
|
||||
- vérification : le second slot, et le ticket « en vol » ouvert sur lui
|
||||
(`side`).
|
||||
- `EngineSummarizer` : un résumé est désormais compté en vol, comme
|
||||
`postSummary`. Il part sur le second slot s'il est fait sur la
|
||||
transcription, sur le slot 0 s'il est fait en continuation.
|
||||
- La passe de vérification est encadrée par `/internal/v1/engine/sidejob`.
|
||||
À la fin, le Go efface le slot si le moteur a servi.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go (`facade_slots_test.go`) :
|
||||
- sans la clé, aucun slot et `/props` jamais lu ;
|
||||
- fil de la discussion sur le 0, le reste sur le 1 ;
|
||||
- second slot hors service ;
|
||||
- rechargement une seule fois, pour la bonne discussion et une étape de tour
|
||||
seulement ;
|
||||
- une étape de la façade acceptée par le moteur suffit à la sauvegarde ;
|
||||
- corps illisible, GET refusé.
|
||||
- La boucle du tour :
|
||||
- `id_slot` dans chaque requête, slot demandé avant chaque envoi ;
|
||||
- cache KV plein : rejoué deux fois sans compaction, puis l'erreur ;
|
||||
- sans slot, ou moteur externe : un débordement comme un autre ;
|
||||
- motif `sideSlotPoolError` repris des cas du Go.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- contrat du mode Code : le tour sur le 0, la vérification sur le 1 ;
|
||||
- tickets ouverts sur le second slot, travail annexe ouvert puis rendu ;
|
||||
- résumés : transcription sur le 1, continuation sur le 0, tickets rendus.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Le rejeu « cache KV plein » se fie au slot demandé pour cette requête. Le Go
|
||||
relit `sideSlotLive` au moment de l'erreur ; les deux lectures partagent
|
||||
le même cache de dix secondes.
|
||||
- Le slot est demandé avant de composer la requête, et non juste avant de
|
||||
l'envoyer : rien ne se passe entre les deux.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 32 — Images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Sans la clé, une image montrée par un outil (capture, `see_image`) est
|
||||
éphémère. Le modèle la voit pendant le tour, puis elle disparaît de
|
||||
l'historique. Le tour suivant renvoie donc un fil où elle manque : le
|
||||
préfixe en cache diverge à elle, et toute la boucle d'outils qui suivait est
|
||||
recalculée.
|
||||
|
||||
`KEEP_TURN_IMAGES` (`chat_keep_images.go`) garde l'image telle qu'envoyée,
|
||||
sous conditions :
|
||||
|
||||
- vision réellement active ;
|
||||
- image rangée par référence et relue à l'octet près ;
|
||||
- coût **mesuré** par le moteur, jamais estimé ;
|
||||
- toutes les images gardées tiennent dans 10 % de la fenêtre.
|
||||
|
||||
Les images gardées partent avant tout résumé. La clé gardait la conversation
|
||||
au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
### Go (`facade_keepimg.go`)
|
||||
|
||||
- `/internal/v1/turn/keepimg` rend deux choses :
|
||||
- si la vision est réellement active (`visionEnabled`, `engineSeesImages`) ;
|
||||
- le budget des images gardées (`keepImgBudget`).
|
||||
- `/internal/v1/turn/keepimg/store` range une image relayée par référence
|
||||
(`refRelayImage`) :
|
||||
- fichier chiffré si la mémoire l'est ;
|
||||
- date rafraîchie ;
|
||||
- relu à l'octet près.
|
||||
|
||||
Il rend le message qui la porte ainsi et son poids en octets, ou un refus.
|
||||
- `KEEP_TURN_IMAGES` ne garde plus la conversation.
|
||||
|
||||
### Rust
|
||||
|
||||
- `chat/keepimg.rs` porte l'état du tour (`keepImages`) :
|
||||
- `relay` : image gardée à l'essai, dans `messages` et `extra` ;
|
||||
- `sending` ;
|
||||
- `observe` : mesure de la requête qui l'a montrée la première, partage au
|
||||
prorata des octets, seuil de 16 jetons, budget ;
|
||||
- `rewritten` ;
|
||||
- `publishable` ;
|
||||
- `finish` : une image à l'essai jamais mesurée redevient éphémère.
|
||||
|
||||
Une image refusée arrête le tour pour les suivantes.
|
||||
- La boucle du tour :
|
||||
- `TurnHost::keep_images` donne le budget, mais seulement pour le fil
|
||||
principal d'une discussion, avec la clé et la vision active ;
|
||||
- `TurnHost::keep_image` range l'image par le Go ;
|
||||
- l'historique réécrit en cours de tour est publié sans les relais non
|
||||
gardés.
|
||||
- Les images gardées partent d'abord, avant tout résumé :
|
||||
- au filet réactif (prompt trop long) : requête rejouée sans elles ;
|
||||
- en cours de tour, au seuil : la compaction ne suit que si elle reste
|
||||
nécessaire ;
|
||||
- sur une complétion coupée par une fenêtre pleine : rejouée sans résumé
|
||||
si leur retrait suffit.
|
||||
- Début et fin de tour (`keptImagesTurnStart`) : les images gardées sont
|
||||
retirées si le contexte le demande, ou si elles ne doivent plus partir
|
||||
(clé retirée, vision absente).
|
||||
|
||||
La compaction et la réduction forcée retiraient déjà les images gardées
|
||||
d'abord (`chat/compact.rs`).
|
||||
|
||||
## Tests
|
||||
|
||||
- `chat/keepimg` :
|
||||
- clé ;
|
||||
- gardée au coût mesuré ;
|
||||
- budget ;
|
||||
- sans mesure (pas d'usage, complétion coupée, pas de base) ;
|
||||
- requête relancée ;
|
||||
- rangement refusé ;
|
||||
- partage entre deux images ;
|
||||
- publication, réécriture, fin de tour, budget plein avant rangement.
|
||||
- La boucle du tour :
|
||||
- requête identique avec et sans la clé, image gardée par référence au
|
||||
coût mesuré ;
|
||||
- refus : budget, pas d'usage, rangement refusé ;
|
||||
- image à l'essai en fin de tour ;
|
||||
- retrait en cours de tour, au filet réactif et sur une fenêtre pleine, à
|
||||
chaque fois sans résumé.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- image rangée par le Go, vue en data-URL par le moteur, gardée avec son
|
||||
coût ;
|
||||
- clé retirée : l'image part au début du tour suivant, légende et marque
|
||||
gardées.
|
||||
- Le Go (`facade_keepimg_test.go`) :
|
||||
- état sans projecteur puis vu par le moteur, budget ;
|
||||
- rangement relu à l'octet près ;
|
||||
- refus : texte seul, URL distante, format inconnu, base64 illisible, deux
|
||||
images ;
|
||||
- corps illisible, GET refusé.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Lot 33 — Préchauffage (PREWARM) tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Certains recalculs sont inévitables :
|
||||
|
||||
- le prompt réécrit par une compaction ;
|
||||
- le dernier message assistant rendu autrement au tour suivant ;
|
||||
- la discussion reprise après une tâche qui a pris le slot.
|
||||
|
||||
Sans la clé, ils tombent sur le premier jeton du message suivant.
|
||||
|
||||
`PREWARM` (`chat_prewarm.go`) envoie au moteur local, dès qu'il est libre, la
|
||||
requête du prochain tour telle qu'elle partira, suivie d'une sentinelle « . »
|
||||
et avec `max_tokens` 1. Le moteur calcule tout le préfixe ; le vrai tour ne
|
||||
calcule plus que son propre message.
|
||||
|
||||
Le préchauffage ne passe jamais devant un vrai travail :
|
||||
|
||||
- toute requête de Loki l'annule, sauf un tour qui le prolonge exactement ;
|
||||
- un seul est en vol à la fois ;
|
||||
- aucune erreur ne remonte.
|
||||
|
||||
La clé gardait la conversation au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
La façade tient l'historique : c'est elle qui assemble et envoie. Le Go
|
||||
garde le compteur des requêtes en vol (`engineGate`) et ce qui annule.
|
||||
|
||||
### Go (`facade_prewarm.go`)
|
||||
|
||||
- `/internal/v1/prewarm/check` dit si le préchauffage peut partir :
|
||||
- clé posée, moteur local ;
|
||||
- ni travail annexe, ni bench, ni optimiseur en cours ;
|
||||
- loin de minuit ;
|
||||
- moteur en bonne santé ;
|
||||
- slot visé : un seul slot, ou celui de la discussion avec SIDE_SLOT.
|
||||
- `/internal/v1/prewarm/begin` :
|
||||
- recharge l'état SLOT_PERSIST de la discussion ;
|
||||
- inscrit le préchauffage comme LA requête en vol (`prewarmBegin`) ;
|
||||
- son annulation (une autre requête part) est relayée à la façade
|
||||
(`/internal/facade/v1/chat/prewarm/cancel`).
|
||||
- `/internal/v1/prewarm/end` :
|
||||
- rend la place ;
|
||||
- note ce que le moteur a repris (`noteEnginePrompt`).
|
||||
- `/internal/v1/oai/begin` accepte `prewarm_keep` : l'étape d'un tour de la
|
||||
façade qui prolonge le préchauffage ne l'annule pas.
|
||||
- Après une tâche planifiée, le déclencheur part à la façade
|
||||
(`/internal/facade/v1/chat/prewarm`).
|
||||
- PREWARM ne garde plus la conversation.
|
||||
|
||||
### Rust (`chat/prewarm.rs`)
|
||||
|
||||
- L'état partagé :
|
||||
- le préchauffage en vol, son inscription, son annulation ;
|
||||
- les capacités du dernier tour de chaque discussion (64 au plus).
|
||||
- L'assemblage : les mêmes fonctions que le tour.
|
||||
- Vue du Go, historique sans blocs `<context_update>`, préambule.
|
||||
- Écho, messages d'envoi, corps.
|
||||
- Sentinelle « . » (rôle `builder` en mode Code), non streamé, un seul jeton.
|
||||
- On s'abstient dans ces cas :
|
||||
- capacités inconnues ;
|
||||
- tour en cours ;
|
||||
- discussion vide ;
|
||||
- compaction attendue ;
|
||||
- assemblage inattendu.
|
||||
- Déclencheurs :
|
||||
- fin de tour quand rien n'attend dans la file ;
|
||||
- fin de tâche (relayée par le Go) ;
|
||||
- changement de discussion ou de projet : le préchauffage en cours est
|
||||
annulé ; avec `full`, la nouvelle discussion est préparée après 3 s.
|
||||
- Le tour :
|
||||
- `TurnHost::prewarm_keeps` compare les empreintes des messages envoyés
|
||||
et des champs qui façonnent le rendu (`prewarmOptsHash`) ;
|
||||
- une requête qui ne prolonge pas le préchauffage le coupe tout de suite,
|
||||
sans attendre l'aller-retour par le Go.
|
||||
- Télémétrie : le préchauffage est rangé dans l'anneau (`kind=prewarm`).
|
||||
|
||||
## Tests
|
||||
|
||||
- `chat/prewarm` :
|
||||
- mode (jamais pour un preset externe) ;
|
||||
- empreinte des options (l'échantillonnage n'y entre pas, un champ absent
|
||||
n'est pas un champ vide) ;
|
||||
- rôle de la sentinelle ;
|
||||
- prolongement ;
|
||||
- état partagé (un seul en vol, garde, annulation, fin) ;
|
||||
- capacités bornées.
|
||||
- La boucle du tour : le drapeau transmis à l'envoi, jamais vers une API
|
||||
externe.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- fin de tour : requête préchauffée (sentinelle, un seul jeton, même
|
||||
préfixe et mêmes outils que le tour), inscrite au Go, pas de ticket de
|
||||
la façade ;
|
||||
- le tour suivant la prolonge et la garde (`prewarm_keep`) ;
|
||||
- fin rendue avec total et cache, télémétrie ;
|
||||
- refus du Go : rien ne part ;
|
||||
- annulation relayée : connexion coupée, place rendue.
|
||||
- Le Go (`facade_prewarm_test.go`) :
|
||||
- contrôle : clé, slots, minuit, travail annexe, moteur muet ;
|
||||
- inscription et deuxième préchauffage refusé ;
|
||||
- gardé par le tour qui le prolonge, annulé et relayé sinon ;
|
||||
- fin unique ;
|
||||
- requête déjà en vol ;
|
||||
- déclencheur après tâche relayé.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- L'annulation d'un préchauffage pas encore inscrit au Go (aller-retour en
|
||||
cours) annule celui qui part, quel que soit son numéro : il n'y en a
|
||||
qu'un.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lot 34 — Bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le contexte du projet part en tête du premier message utilisateur :
|
||||
|
||||
- description ;
|
||||
- index mémoire ;
|
||||
- trackers ;
|
||||
- consignes du dépôt ;
|
||||
- avec la clé, la date et le dossier de travail.
|
||||
|
||||
Reconstruit à chaque tour, il change dès qu'une page est créée ou qu'un
|
||||
tracker reçoit un point. Tout ce qui le suit dans le prompt est alors
|
||||
recalculé.
|
||||
|
||||
`PROJ_SNAPSHOT` (`chat_projsnap.go`) garde une copie figée du bloc par
|
||||
discussion. Les changements arrivent en `<context_update>` en tête du
|
||||
nouveau message utilisateur, rangés tels quels dans l'historique. Le bloc
|
||||
n'est rafraîchi que quand le début du prompt change de toute façon :
|
||||
compaction, système ou outils, clé du moteur, projet, mode, trop de mises à
|
||||
jour accumulées.
|
||||
|
||||
C'était le dernier réglage qui gardait la conversation au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
L'instantané est déjà persisté avec la discussion (`ConvState::proj_snap`),
|
||||
et la compaction le remet à zéro comme le Go. Il manquait la décision, qui
|
||||
lit le contexte du projet : elle reste au Go.
|
||||
|
||||
### Go (`facade_projsnap.go`)
|
||||
|
||||
- `/internal/v1/turn/view`, avec la clé et l'agent :
|
||||
- reçoit l'instantané de la discussion (`snap`) ;
|
||||
- rend la tête du tour (prompt du preset, puis le bloc figé, ou le vivant
|
||||
s'il faut rafraîchir) et le préambule sans date ni dossier
|
||||
(`envInCtx`) ;
|
||||
- rend aussi `projsnap` : rafraîchir ou non, la mise à jour à poser,
|
||||
l'instantané à ranger, la clé et l'empreinte du système du tour.
|
||||
- `dry` (préchauffage) : la même décision, rien à ranger.
|
||||
- `/internal/v1/turn/projsnap/refresh` : le bloc vivant et l'instantané qu'il
|
||||
devient (`projSnapTurn.refresh`).
|
||||
- Plus aucun réglage ne garde la conversation : `facadeChatBlocker` et la
|
||||
décision prise une fois par processus disparaissent. Le Go la remet dès
|
||||
que `LOKI_CHAT=rust`, l'adresse de la façade et le jeton sont là.
|
||||
|
||||
### Rust
|
||||
|
||||
- `ChatService::assemble_turn`, partagé par le tour et la relance du builder
|
||||
du mode Code :
|
||||
- rafraîchi : instantané neuf, blocs `<context_update>` retirés de
|
||||
l'historique ;
|
||||
- mise à jour : posée en tête du dernier message utilisateur
|
||||
(`prependCtxUpdate`), dans la discussion et dans la requête ; l'état
|
||||
annoncé ne bouge que si elle a trouvé où se poser ;
|
||||
- sans la clé : la discussion qui porte encore des blocs en est nettoyée,
|
||||
comme avant.
|
||||
- `TurnHost::project_refresh` : après une compaction ou une réduction en
|
||||
plein tour, le bloc vivant remplace le figé (`replaceProjectHead`) et les
|
||||
mises à jour sont retirées. L'instantané posé devient celui de la
|
||||
discussion à la fin du tour ou de la passe. Le retrait d'images gardées ne
|
||||
touche pas au début du prompt : pas de rafraîchissement.
|
||||
- Le préchauffage demande la vue en `dry`. Il garde les mises à jour de
|
||||
l'historique tant que le bloc n'est pas rafraîchi, comme le vrai tour.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go (`facade_projsnap_test.go`) :
|
||||
- la séquence assemblée par la façade égale celle de `turnView`, outils et
|
||||
instantané compris ;
|
||||
- une page ajoutée : bloc gardé, mise à jour égale à celle de `turnView`,
|
||||
état annoncé suivi ;
|
||||
- sans changement : rien à poser ;
|
||||
- `dry` sans rien à ranger ;
|
||||
- clé changée : rafraîchi, bloc vivant en préchauffage ;
|
||||
- rafraîchissement en plein tour : bloc vivant, instantané à la clé du
|
||||
tour.
|
||||
- Go (`facade_chat_test.go`) : avec toutes les clés opt-in posées, la façade
|
||||
tient la conversation ; sans adresse ou sans `LOKI_CHAT`, le Go et
|
||||
pourquoi.
|
||||
- Rust, `chat/compact` : mise à jour en tête (texte, parties, refus) ; bloc
|
||||
projet remplacé à sa place (cas du Go).
|
||||
- Rust, boucle du tour : rafraîchi après une compaction et une réduction ;
|
||||
pas après un retrait d'images gardées.
|
||||
- Rust, vrai hôte (`tests/lot6.rs`) :
|
||||
- instantané rangé ;
|
||||
- mise à jour posée dans la requête et l'historique (marque retirée à
|
||||
l'envoi) ;
|
||||
- rafraîchissement qui retire les mises à jour ;
|
||||
- compaction en plein tour : bloc vivant envoyé, instantané rangé ;
|
||||
- préchauffage en `dry`.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Lot 35 — Routes de base par le tunnel du relais
|
||||
|
||||
## Contexte
|
||||
|
||||
La façade sert toujours neuf routes, sans variable (`api.rs`) :
|
||||
|
||||
- `/api/ping` ;
|
||||
- `/api/status` ;
|
||||
- `/api/config` ;
|
||||
- `/api/engine/cmdline` ;
|
||||
- `/api/model/layers` ;
|
||||
- `/api/paths` ;
|
||||
- `/api/ram` ;
|
||||
- `/api/vram` ;
|
||||
- `/api/service/log`.
|
||||
|
||||
Mais le tunnel du relais (accès distant, E2E) sert le mux du Go tel quel.
|
||||
`withFacadeChat` ne remet à la façade que les routes des lots. Un utilisateur
|
||||
distant tombait donc encore sur les handlers Go de ces neuf routes : deux
|
||||
implémentations à garder d'accord.
|
||||
|
||||
## Le lot
|
||||
|
||||
- Go (`facade_core.go`) : une table des routes de base, remises à la façade
|
||||
par `withFacadeChat` dès qu'elle tourne (adresse et jeton interne
|
||||
présents), sans variable. Façade muette : 502 « façade indisponible ».
|
||||
Sans façade (`LOKI_FACADE=0`), le Go les sert lui-même.
|
||||
- Rust (`api.rs`) : une requête relayée par le Go (jeton interne) a déjà été
|
||||
authentifiée par lui. Ni clé ni hôte local ne sont exigés de nouveau,
|
||||
comme pour les routes des lots ; la version du Go est lue quand même pour
|
||||
`/api/ping`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go (`facade_core_test.go`) :
|
||||
- les neuf routes relayées avec le jeton ;
|
||||
- une route du Go gardée ;
|
||||
- façade muette ;
|
||||
- sans façade, servies ici.
|
||||
- Rust (`tests/lot2.rs`) :
|
||||
- relayée par le Go avec un hôte distant et une clé posée : servie par la
|
||||
façade, pas renvoyée au Go ;
|
||||
- sans jeton, ou avec un faux : refusée.
|
||||
|
||||
## Reste à faire
|
||||
|
||||
L'interface statique et `/v1` passent encore par le Go sur le tunnel. Ses
|
||||
handlers sont gardés pour `LOKI_FACADE=0`.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Lot 36 — Appels d'outils MCP sans détour par le Go
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 20, la façade tient le pool MCP (`LOKI_MCP=rust`) : sessions
|
||||
stdio et HTTP, registre des outils exposés. Le tour de la façade, lui,
|
||||
faisait encore exécuter un appel `mcp__*` par le Go
|
||||
(`/internal/v1/tool/exec`).
|
||||
|
||||
Le Go ne l'exécutait pas lui-même : `mcpCall` renvoyait l'appel à la façade
|
||||
(`/internal/facade/v1/mcp/call`). Chaque appel faisait donc deux
|
||||
allers-retours locaux pour revenir au même pool.
|
||||
|
||||
Pour un outil MCP, le Go n'ajoute rien au résultat (`toolStepExtras`) :
|
||||
|
||||
- ni capture ;
|
||||
- ni aperçu du navigateur ;
|
||||
- ni image relayée.
|
||||
|
||||
Seul le texte revient.
|
||||
|
||||
## Le lot
|
||||
|
||||
- `ChatShared` porte le pool (`mcp`), le même que celui des routes (créé une
|
||||
fois dans `main.rs`).
|
||||
- `GoTurnHost::exec_tool` : un nom `mcp__*`, pool tenu → `Mcp::call`
|
||||
directement. Le résultat est celui de la route interne :
|
||||
- texte aplati et coupé ;
|
||||
- « [l'outil a renvoyé une erreur] » ;
|
||||
- « [ok] (aucune sortie) » ;
|
||||
- une reconnexion si la session est morte, puis « [erreur MCP] … ».
|
||||
- Une reconnexion annonce la version du Go dans `clientInfo`, comme la route
|
||||
interne. Elle est lue une fois (`/internal/v1/auth`) et retenue.
|
||||
- Sans pool tenu (`LOKI_MCP` absent), rien ne change : l'appel part au Go,
|
||||
qui l'exécute avec son propre pool.
|
||||
|
||||
Le Go garde la route interne pour les tours qu'il exécute encore :
|
||||
|
||||
- sous-agents ;
|
||||
- tâches IA ;
|
||||
- discussion rendue au Go.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/lot6.rs` (`outil_mcp_par_le_pool_de_la_facade`) fait tourner un faux
|
||||
serveur MCP stdio. Un tour de la façade appelle :
|
||||
|
||||
- un outil qui répond ;
|
||||
- un outil dont le serveur meurt.
|
||||
|
||||
Le test vérifie :
|
||||
|
||||
- aucun appel au Go ;
|
||||
- le résultat de chaque outil tel que le modèle le reçoit ;
|
||||
- l'erreur après une reconnexion ;
|
||||
- la version du Go annoncée à cette reconnexion.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 37 — Outils task_* de l'agent en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 26, la façade tient les tâches planifiées (`LOKI_TASKS=rust`) :
|
||||
|
||||
- les routes ;
|
||||
- le planificateur ;
|
||||
- les tâches « script seul ».
|
||||
|
||||
Les quatre outils par lesquels le modèle gère lui-même ses tâches
|
||||
(`tasks_tools.go`) restaient au Go, appelés par `/internal/v1/tool/exec` :
|
||||
|
||||
- `task_list` ;
|
||||
- `task_create` ;
|
||||
- `task_update` ;
|
||||
- `task_delete`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/tasks/tools.rs` porte les quatre outils, mêmes messages au caractère
|
||||
près :
|
||||
|
||||
- **`task_list`** :
|
||||
- liste du projet actif, aperçu d'une ligne par tâche ;
|
||||
- compte-rendu coupé à 300 octets, un caractère coupé valant U+FFFD par
|
||||
octet comme à l'encodage JSON du Go ;
|
||||
- avec un id, la tâche seule et son compte-rendu complet ;
|
||||
- une tâche d'un autre projet est « introuvable » ;
|
||||
- **`task_create`** :
|
||||
- tâche unique « dans N minutes » (durée tronquée comme
|
||||
`time.Duration(v)`) ou « à HH:MM » (le lendemain si l'heure est passée,
|
||||
à la même heure murale) ou « à AAAA-MM-JJ HH:MM » ;
|
||||
- fréquence validée ;
|
||||
- script vérifié dans le dossier protégé ;
|
||||
- tâche rattachée au projet actif ;
|
||||
- fuseau : celui du navigateur, sinon celui de la première tâche qui en a
|
||||
un ;
|
||||
- **`task_update`** : modification partielle ; la prochaine exécution est
|
||||
recalculée sur une nouvelle fréquence, ou à la reprise d'une tâche qui
|
||||
n'en a pas ;
|
||||
- **`task_delete`**.
|
||||
|
||||
Un projet disparu vaut le projet par défaut (`taskProjectOf`).
|
||||
|
||||
`GoTurnHost::exec_tool` les exécute quand la façade tient les tâches
|
||||
(`ChatShared.tasks`, la même base que celle des routes). Sinon l'appel part
|
||||
au Go comme avant.
|
||||
|
||||
Côté Go, les outils prennent l'heure des tâches (`tasksNow`) au lieu de
|
||||
`time.Now`. Rien ne change en service ; le journal de parité peut ainsi la
|
||||
fixer.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tasks_tools_test.go` tire du Go un journal de 44
|
||||
appels (`tests/parity_tasks/tools.json`). Chaque étape porte :
|
||||
|
||||
- l'heure, le fuseau, le projet actif, les projets connus ;
|
||||
- les scripts présents ;
|
||||
- une tâche posée telle quelle en base ;
|
||||
- les arguments tels que le modèle les envoie ;
|
||||
- le résultat tel qu'il le reçoit ;
|
||||
- les tâches en base après coup.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- l'heure du trou du changement d'heure ;
|
||||
- une date passée ou impossible ;
|
||||
- des arguments d'un autre type ;
|
||||
- un script absent, hostile ou dossier ;
|
||||
- le compte-rendu coupé au milieu d'un caractère ;
|
||||
- le fuseau d'une tâche existante.
|
||||
|
||||
`tests/tasks.rs` (`parite_des_outils`) le rejoue contre le port Rust.
|
||||
|
||||
`tests/lot6.rs` (`outil_task_par_la_facade`) fait tourner un tour de la
|
||||
façade qui crée une tâche. Il vérifie :
|
||||
|
||||
- rien ne passe par le Go ;
|
||||
- la tâche est rangée au fuseau de l'utilisateur, dans le projet actif ;
|
||||
- le résultat est celui que le modèle reçoit.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Lot 38 — L'outil criteria du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le modèle tient les critères d'acceptation de sa tâche avec
|
||||
l'outil `criteria` :
|
||||
|
||||
- les poser ;
|
||||
- les marquer faits ;
|
||||
- les lister ;
|
||||
- les effacer.
|
||||
|
||||
La passe de vérification les marque ensuite `passed` ou `failed`.
|
||||
|
||||
Depuis le lot 19, la façade sert déjà les critères de l'écran
|
||||
(`/api/chat/criteria`, `chat/crit:<id>` scellés). La passe de vérification
|
||||
les relit depuis le lot 7. Mais l'outil lui-même partait au Go
|
||||
(`/internal/v1/tool/exec`), qui le renvoyait à la façade comme delta
|
||||
(`critNotify` → `/internal/facade/v1/delta`).
|
||||
|
||||
## Le lot
|
||||
|
||||
`chat/crittool.rs` porte `toolCriteria`, mêmes messages au caractère près :
|
||||
|
||||
- **list** : action vide ou d'un autre type comprise ;
|
||||
- **add** :
|
||||
- textes vides ou d'un autre type ignorés ;
|
||||
- ids repris après le plus grand ;
|
||||
- vingt critères au plus ;
|
||||
- **set** :
|
||||
- id numérique (tronqué) ou en chaîne (« "2" », « "#2" », « +1 ») ;
|
||||
- statut libre, note rognée ;
|
||||
- `passed` refusé hors de la passe de vérification ;
|
||||
- **clear**.
|
||||
|
||||
Les règles de rangement et d'affichage :
|
||||
|
||||
- une liste vide supprime la clé ; une erreur d'écriture est ignorée, comme
|
||||
dans le Go ;
|
||||
- une liste rangée illisible vaut « aucun critère » ;
|
||||
- la note vide n'est pas écrite (`omitempty`).
|
||||
|
||||
`GoTurnHost::exec_tool` l'exécute pour la discussion du tour
|
||||
(`main_conv`). La passe de vérification (`Role` = verifier) est la seule à
|
||||
pouvoir marquer `passed`. Chaque changement pousse la liste à l'écran (delta
|
||||
`criteria` + `ts`) par un nouveau champ `deltas` de l'hôte :
|
||||
|
||||
- le journal de la discussion pour un tour ;
|
||||
- celui de la conversation pour une passe de vérification.
|
||||
|
||||
Écart assumé : le Go rangeait les critères sous la discussion **active**
|
||||
au moment de l'appel (`convEnsureActive`). La façade les range sous celle du
|
||||
tour. Elles ne diffèrent que si l'utilisateur change de discussion pendant
|
||||
la génération, et c'est alors la façade qui a raison.
|
||||
|
||||
`see_image` reste au Go. Il redresse et réduit l'image (EXIF, taille) avant
|
||||
de l'encoder, ce qu'un port ne rendrait pas octet pour octet.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_criteria_test.go` tire du Go un journal de 22 appels
|
||||
(`tests/parity_tools/criteria.json`). Chaque étape porte :
|
||||
|
||||
- les arguments, builder ou vérification ;
|
||||
- le résultat ;
|
||||
- les critères rangés ensuite.
|
||||
|
||||
`tests/tools.rs` (`criteria_comme_le_go`) le rejoue contre le port. Il
|
||||
vérifie aussi :
|
||||
|
||||
- le rangement scellé ;
|
||||
- la liste poussée à l'écran à chaque changement, et seulement alors.
|
||||
|
||||
`tests/lot6.rs` (`contrat_verifie_par_la_facade`) fait tourner un vrai
|
||||
contrat. La passe de vérification marque le critère `passed` dans la
|
||||
façade : rien ne passe par le Go, le critère est rangé scellé, l'écran
|
||||
reçoit la liste.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lot 39 — Anneau de télémétrie tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Chaque complétion qui passe par le moteur est rangée dans un anneau
|
||||
(`perf_log.go`). L'anneau :
|
||||
|
||||
- calcule la perte de cache d'une complétion contre la précédente de la même
|
||||
discussion et de la même nature ;
|
||||
- nomme ce qui s'est intercalé (sous-agent, client externe…) ;
|
||||
- signale une perte au-delà du seuil du preset (stats finales) ;
|
||||
- nourrit `/api/perf/summary` et les poids de l'optimiseur.
|
||||
|
||||
Depuis le lot 29, la façade envoyait chaque complétion de ses tours au Go
|
||||
(`/internal/v1/turn/perf`). C'était un aller-retour par complétion pour un
|
||||
anneau tenu ailleurs.
|
||||
|
||||
## Le lot
|
||||
|
||||
**Rust** (`src/perfring.rs`, port de `perf_log.go`) :
|
||||
|
||||
- l'anneau : 5000 entrées, 128 discussions suivies, 500 entrées remontées au
|
||||
plus pour nommer ce qui s'est intercalé ;
|
||||
- la perte : seulement si la complétion prolonge strictement la précédente
|
||||
(empreintes cumulées des messages), cache connu, complétion allée au bout ;
|
||||
- la ligne `[perf]` (LOKI_PERF_LOG) ;
|
||||
- le seuil d'alerte, dans le même ordre de priorité qu'au lancement :
|
||||
- `-cms` d'EXTRA_ARGS ;
|
||||
- `LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT` ;
|
||||
- `CKPT_MIN_STEP` ;
|
||||
- 2048 sur un modèle hybride ;
|
||||
- plus le micro-batch ;
|
||||
- le résumé de `/api/perf/summary` (par nature, par profondeur, brouillon,
|
||||
dernier verdict de la sonde de gabarit).
|
||||
|
||||
`ChatShared` porte l'anneau. Les tours, les passes de vérification, les
|
||||
résumés de compaction et le préchauffage de la façade y rangent leurs
|
||||
complétions directement. Les marques (perte, ce qui s'est intercalé,
|
||||
alerte) partent sur les stats finales comme avant.
|
||||
|
||||
La façade sert :
|
||||
|
||||
- `POST /internal/facade/v1/perf/record` : une complétion faite par le Go
|
||||
(sous-agent, tâche IA, bench, compaction et préchauffage de ses tours,
|
||||
client externe de `/v1`), rendue telle que rangée ;
|
||||
- `POST /internal/facade/v1/perf/snapshot` : l'anneau, pour les poids de
|
||||
l'optimiseur ;
|
||||
- `GET /api/perf/summary`.
|
||||
|
||||
**Go** (`facade_perf.go`) : quand la façade tient la conversation,
|
||||
`perfRecord` range chez elle, et `perfSnapshot` relit son anneau
|
||||
(optimiseur, contexte de `/internal/v1/tune/context`). `/api/perf/summary`
|
||||
lui est remis (`facadeChatRoutes`). Façade injoignable : l'anneau du Go
|
||||
prend le relais. Sans façade, ou la conversation rendue au Go
|
||||
(`LOKI_CHAT=go`), rien ne change.
|
||||
|
||||
La route `/internal/v1/turn/perf` est retirée : plus personne ne l'appelle.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_perf_test.go` tire du Go un journal
|
||||
(`tests/parity_perf/perf.json`). Quatorze complétions y sont rangées, avec
|
||||
les empreintes de leurs messages ; elles couvrent :
|
||||
|
||||
- prolongement ;
|
||||
- perte après un sous-agent et un client externe ;
|
||||
- cache inconnu ;
|
||||
- divergence ;
|
||||
- autre discussion ;
|
||||
- complétion coupée ;
|
||||
- compaction, bench, vérification.
|
||||
|
||||
Pour chaque complétion, le journal garde ce qui est rangé, la ligne
|
||||
`[perf]` et l'alerte. Il porte aussi le résumé, le résumé vide, et onze
|
||||
seuils d'alerte. `src/perfring/tests.rs` le rejoue, avec l'anneau borné et
|
||||
l'heure posée.
|
||||
|
||||
`tests/lot6.rs` :
|
||||
|
||||
- `cache_perdu_mesure_par_la_facade` : deux tours de la même discussion ;
|
||||
la perte est mesurée par la façade et signalée dans les stats finales ;
|
||||
- `anneau_de_la_facade_pour_le_go` : ce que le Go confie (perte après un
|
||||
client externe), ce qu'il relit, le jeton exigé, `/api/perf/summary`
|
||||
servi par la façade ;
|
||||
- les tests du lot 29 lisent désormais l'anneau de la façade.
|
||||
|
||||
`internal/loki/facade_perf_test.go` (`TestFacadePerfAnneau`) :
|
||||
|
||||
- complétion confiée à la façade ;
|
||||
- anneau relu ;
|
||||
- repli sur l'anneau du Go quand la façade est en panne ;
|
||||
- route remise.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Lot 40 — Outils mémoire de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 13, la façade tient la mémoire quand `LOKI_MEM=rust` :
|
||||
|
||||
- pages ;
|
||||
- index `MEMORY.md` ;
|
||||
- trackers ;
|
||||
- mode mémoire.
|
||||
|
||||
Les outils par lesquels le modèle s'en sert partaient pourtant au Go
|
||||
(`/internal/v1/tool/exec`) :
|
||||
|
||||
- `mem_search`, `mem_read`, `mem_add`, `mem_edit` ;
|
||||
- `tracker`.
|
||||
|
||||
Deux processus écrivaient donc les mêmes pages et le même index.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/memory/tools.rs` porte les outils (`chat_memory.go`, `tracker.go`),
|
||||
mêmes résultats au caractère près. Il réutilise les lectures et écritures des
|
||||
routes de la mémoire :
|
||||
|
||||
- pages scellées selon le chiffrement ;
|
||||
- `.bak` de la version précédente ;
|
||||
- écriture vérifiée ;
|
||||
- index tenu par le code.
|
||||
|
||||
Chaque outil :
|
||||
|
||||
- **`mem_search`** : classement par couverture des termes puis par
|
||||
pertinence (rareté IDF, fréquence amortie, bonus du nom et du titre) ;
|
||||
extrait coupé à l'octet comme le Go (U+FFFD par octet d'un caractère
|
||||
coupé) ; limite 8 par défaut, 30 au plus ;
|
||||
- **`mem_read`** : lignes numérotées, plage, 500 au plus ;
|
||||
- **`mem_add`** : jamais par-dessus une page existante, index tenu (seed au
|
||||
nom du projet), diff d'ajout ;
|
||||
- **`mem_edit`** : `old` unique, édition déjà en place reconnue, diff ;
|
||||
- **`tracker`** :
|
||||
- vue d'ensemble ;
|
||||
- squelette (années, année en cours par mois) ;
|
||||
- une année par mois ;
|
||||
- points (60 plus récents au plus) ;
|
||||
- ajout, modification, suppression d'un point (jamais d'un tracker
|
||||
entier) ;
|
||||
- arguments d'un autre type rendus comme `str` du Go.
|
||||
|
||||
`tracker_add` prend désormais l'heure en paramètre (`tracker_add_at`).
|
||||
|
||||
`ChatShared.memory` porte la même mémoire que les routes. `GoTurnHost::exec_tool`
|
||||
y exécute les outils quand la façade la tient ; sinon l'appel part au Go
|
||||
comme avant.
|
||||
|
||||
Côté Go, l'heure des trackers (« maintenant », année en cours) passe par
|
||||
`trackerNow`, que le journal fixe ; rien ne change en service.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_mem_tools_test.go` tire du Go un journal de 57 appels
|
||||
(`tests/parity_mem/outils.json`), en fuseau UTC et à heure fixée. Chaque
|
||||
étape porte :
|
||||
|
||||
- les arguments ;
|
||||
- le libellé du tour ;
|
||||
- le résultat, le diff ;
|
||||
- les fichiers de la mémoire du projet (`.bak` et index compris) ;
|
||||
- les trackers en base.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- recherche : couverture, rareté, requête vide, limites, extrait coupé ;
|
||||
- lecture : plage, au-delà, page absente, nom invalide ;
|
||||
- ajout : existant, nom invalide, majuscules ;
|
||||
- modification : rejouée, déjà en place, absente, en double, vide ;
|
||||
- trackers : chaque vue, dates illisibles, nom en nombre, identifiants
|
||||
inconnus.
|
||||
|
||||
`tests/memory.rs` (`outils_memoire_comme_le_go`) le rejoue contre le port.
|
||||
|
||||
`tests/lot6.rs` (`outil_memoire_par_la_facade`) fait tourner un tour de la
|
||||
façade qui crée une page. Il vérifie :
|
||||
|
||||
- rien ne passe par le Go ;
|
||||
- la page est rangée dans le projet actif, l'index tenu ;
|
||||
- le résultat est celui que le modèle reçoit.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Lot 41 — Compilation de llama.cpp depuis les sources dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 22, la façade tient les backends llama.cpp quand
|
||||
`LOKI_BACKENDS=rust` :
|
||||
|
||||
- statut, vérification git ;
|
||||
- binaires précompilés ;
|
||||
- bascule fast/opt ;
|
||||
- backends custom ;
|
||||
- le job des moteurs.
|
||||
|
||||
La compilation depuis les sources restait au Go. Il en rapportait chaque
|
||||
étape au job de la façade par l'API interne
|
||||
(`/internal/facade/v1/build-job/*`). Elle couvre trois routes :
|
||||
|
||||
- `POST /api/llamacpp/install` `{force}` ;
|
||||
- `POST /api/llamacpp/update` `{clean}` ;
|
||||
- `POST /api/llamacpp/install-custom` `{repo, name, ref}`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/backends/build.rs` porte la compilation sous Linux et macOS. Sources Go :
|
||||
|
||||
- `backend_build.go` : plan, CMake, étapes suivies ;
|
||||
- `lcRunInstall`, `lcRunUpdate`, `lcBuildAndSwitch`, `lcAppendLogTail`
|
||||
(`web_llamacpp.go`) ;
|
||||
- `installCustomBackend`, `deriveBackendName`, `looksLikeGitURL`
|
||||
(`backend_llamacpp.go`) ;
|
||||
- `autoInstallTool`, `ensureCompiler` (`sys_platform_unix.go`).
|
||||
|
||||
Le port couvre :
|
||||
|
||||
- **le plan complet** (`detectBuildPlan`) :
|
||||
- drapeaux Release, natif, UI de llama-server coupée ;
|
||||
- Metal sous macOS ;
|
||||
- CUDA quand nvcc et un GPU NVIDIA sont là : F16, racine du toolkit et
|
||||
compilateur explicites hors `/usr`, architectures lues par
|
||||
`nvidia-smi` ;
|
||||
- HIP, puis Vulkan ;
|
||||
- **CMake** :
|
||||
- `build/` configuré ailleurs (dépôt déplacé) ou `clean` : mis de côté en
|
||||
`build.old` ;
|
||||
- nvcc dans le PATH et `CUDACXX` ;
|
||||
- `configure.log` et `build.log` dans le dépôt ;
|
||||
- erreurs « configuration CMake échouée » et « compilation échouée » ;
|
||||
- **les étapes suivies** :
|
||||
- sorties standard et d'erreur dans un même tube, ordre gardé ;
|
||||
- git : découpage du `sinkWriter` (barre qui se redessine montrée toutes
|
||||
les 10 s, reste sans fin de ligne perdu) ;
|
||||
- CMake : découpage du `bufio.Scanner` (dernier morceau rendu, ligne de
|
||||
plus de 1 Mio qui arrête la lecture) ;
|
||||
- octets invalides en U+FFFD un à un ;
|
||||
- erreurs au format d'`exec` (`exit status N`, `signal: killed`,
|
||||
programme introuvable) ;
|
||||
- **les outils** :
|
||||
- git et cmake, puis cc, c++, make ;
|
||||
- un manquant est installé au mieux par apt-get, dnf, pacman ou brew
|
||||
(sudo hors root) ;
|
||||
- sinon le message du Go ;
|
||||
- **les jobs** :
|
||||
- installation : clone, compilation propre, BIN ; dépôt présent : bascule
|
||||
en mise à jour ; `force` : supprimé puis recloné ;
|
||||
- mise à jour : fetch, pull au besoin, commits avant et après ;
|
||||
« déjà à jour » sans recompiler ; service arrêté le temps du build et
|
||||
relancé même en échec ;
|
||||
- échec du build : fin des deux journaux (30 lignes) remontée dans le job ;
|
||||
- backend custom : URL vérifiée, nom tiré du dépôt (propriétaire ajouté à
|
||||
un fork nommé llama.cpp) ou assaini, clone ou mise à jour, ref, sans
|
||||
toucher à BIN.
|
||||
|
||||
Les routes répondent comme le Go :
|
||||
|
||||
- moteur fourni par l'image : 409 et le message du Go ;
|
||||
- custom sans URL : 400 « URL du dépôt requise » ;
|
||||
- optimisation en cours : 409 ;
|
||||
- job déjà lancé : 409 ;
|
||||
- sinon 200 `{"ok":true}` et le job tourne.
|
||||
|
||||
Le service moteur est celui de la façade si elle le pilote, sinon celui du
|
||||
Go (`/internal/v1/service/action`), comme pour les binaires précompilés.
|
||||
|
||||
Côté Go, `facadeBackendsRoutes` gagne les trois routes hors Windows. Sous
|
||||
Windows, la compilation reste au Go, qui rapporte toujours ses étapes à la
|
||||
façade :
|
||||
|
||||
- générateur Visual Studio ;
|
||||
- intégration CUDA de MSBuild ;
|
||||
- `CUDA_PATH`.
|
||||
|
||||
Les messages du terminal du Go (outils manquants, dépendance connue absente
|
||||
après un configure en échec) vont à la sortie d'erreur de la façade.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_build_test.go` (Linux) tire du Go un journal de 15
|
||||
étapes (`tests/parity_backends/build.json`). De faux git, cmake et
|
||||
compilateur, rangés dans le journal, sont seuls sur le PATH. Chaque étape
|
||||
porte :
|
||||
|
||||
- la vue du job (lignes, phase, erreur, commits, fichiers compilés) ;
|
||||
- BIN ;
|
||||
- les dépôts qui ont un `build.old`.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- mise à jour sans dépôt ;
|
||||
- installation, puis installation sur un dépôt présent et installation
|
||||
forcée ;
|
||||
- déjà à jour, nouveau commit, recompilation propre ;
|
||||
- build en échec (fin des journaux) ;
|
||||
- cmake absent, compilateur absent ;
|
||||
- clone en échec ;
|
||||
- custom : URL invalide, nouveau fork, nom assaini sur un ref, dossier
|
||||
existant sur un ref (URL `git@`).
|
||||
|
||||
`tests/backends.rs` (`compilation_comme_le_go`) rejoue le journal par la
|
||||
façade : mêmes faux outils dans le PATH des sondes, POST de la route, job
|
||||
suivi jusqu'au bout. `compilation_refusee` vérifie les refus des routes.
|
||||
|
||||
Les tests unitaires de `build.rs` couvrent :
|
||||
|
||||
- la barre de progression (`TestSinkWriterProgression`) ;
|
||||
- le scanner (fin `\r\n`, dernier morceau, octets invalides, ligne
|
||||
démesurée) ;
|
||||
- la racine du toolkit CUDA (`TestCudaToolkitRoot`) ;
|
||||
- le cache CMake déplacé ;
|
||||
- noms et URL des backends custom ;
|
||||
- le plan par machine (CPU, nvcc du PATH, toolkit versionné,
|
||||
architectures, HIP, Vulkan) ;
|
||||
- les erreurs des commandes (code de sortie, signal, programme absent,
|
||||
journal écrit).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Lot 42 — Outils web de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La façade tient la conversation par défaut. Les outils web de ses tours
|
||||
partaient pourtant au Go (`/internal/v1/tool/exec`) :
|
||||
|
||||
- `web_search` ;
|
||||
- `web_images` ;
|
||||
- `web_open`, `web_read`, `web_grep`.
|
||||
|
||||
Le cache des pages (10 min) était donc au Go : `web_open` puis `web_read`
|
||||
faisaient chacun un aller-retour.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/webtools.rs` porte les outils. Sources Go :
|
||||
|
||||
- `chat_internet_tools.go` : les quatre outils de lecture et de recherche ;
|
||||
- `chat_internet.go` : client Crawl4AI, cache, lecture des résultats
|
||||
DuckDuckGo ;
|
||||
- `chat_web_images.go` : recherche d'images.
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **le cache** :
|
||||
- clé : l'URL, suivie de l'empreinte MD5 des options qui changent la page
|
||||
(actions, attente, bandeaux), à l'échappement HTML près du Go ;
|
||||
- `web_read` et `web_grep` lisent l'ouverture la plus récente d'une URL,
|
||||
toutes options confondues ;
|
||||
- 10 minutes ;
|
||||
- **Crawl4AI** :
|
||||
- corps envoyé au Go près (clés triées, échappement HTML) ;
|
||||
- clé d'accès en `Bearer` ;
|
||||
- réponse `{results:[…]}` ou résultat seul, lue comme `json.Unmarshal`
|
||||
(types faux, `null`, casse des clés) ;
|
||||
- markdown en chaîne ou `{raw_markdown, fit_markdown}` ;
|
||||
- erreurs : HTTP (fin du corps), échec du crawl, réponse vide, serveur
|
||||
injoignable (texte de l'erreur du client Go) ;
|
||||
- **les lignes** : `\r\n`, blancs de fin, vides et doublons consécutifs
|
||||
réduits ; plan des titres ;
|
||||
- **la recherche par Crawl4AI** : titres, liens `uddg` décodés deux fois
|
||||
comme le Go, entités HTML, extrait tiré des liens qui suivent, doublons et
|
||||
liens DuckDuckGo écartés, défi anti-bot ;
|
||||
- **les images** :
|
||||
- jeton `vqd` de la page, puis `i.js` ;
|
||||
- mêmes en-têtes ;
|
||||
- cookies gardés par hôte, comme le jar du client Go ;
|
||||
- réponse lue comme `json.Unmarshal` (erreurs de syntaxe et de type au
|
||||
texte du Go) ;
|
||||
- **`web_grep`** :
|
||||
- regex du Go (`(?i)`, classes ASCII) ;
|
||||
- erreurs au texte de `regexp` pour les fautes courantes ;
|
||||
- **le plafond** de 8000 caractères ;
|
||||
- **les arguments** : nombres tronqués comme `int(float64)` du Go.
|
||||
|
||||
Le moteur intégré lit le HTML avec go-readability, html-to-markdown et le
|
||||
parseur HTML5 de x/net : rien en Rust ne les reproduit au caractère près.
|
||||
Ces deux étapes restent au Go, derrière deux appels sans état :
|
||||
|
||||
- `POST /internal/v1/web/fetch` `{url}` → `{markdown}` ou `{error}`
|
||||
(`goFetchMarkdown`) ;
|
||||
- `POST /internal/v1/web/search` `{query, limit}` → `{results}` ou `{error}`
|
||||
(`goSearch`).
|
||||
|
||||
`ChatShared.web` porte les outils ; `GoTurnHost::exec_tool` les exécute
|
||||
avant tout le reste. `web_screenshot` reste au Go (navigateur piloté).
|
||||
|
||||
Le traducteur des regex du Go (`go_regex`) refuse désormais :
|
||||
|
||||
- la répétition d'une répétition (`a**`, `a*??`, `a{2}*`) ;
|
||||
- un compte au-delà de 1000 ou inversé.
|
||||
|
||||
Un `{` qui n'ouvre pas de répétition y devient littéral, comme au Go. Les
|
||||
motifs `retry_patterns` en profitent.
|
||||
|
||||
Côté Go, pour le journal :
|
||||
|
||||
- `webNow` : l'horloge du cache ;
|
||||
- `ddgImagesBase` et `ddgHTMLBase` : les adresses de DuckDuckGo.
|
||||
|
||||
## Écart connu
|
||||
|
||||
reqwest pose `Accept: */*` sur une requête qui n'en a pas ; le client Go
|
||||
n'en pose aucun. Seule la page de recherche d'images est concernée, sans
|
||||
effet sur DuckDuckGo.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_web_tools_test.go` tire du Go un journal de 74
|
||||
appels (`tests/parity_web/outils.json`). Chaque appel porte :
|
||||
|
||||
- le résultat ;
|
||||
- les requêtes reçues par le faux Crawl4AI (méthode, clé, type, corps) ;
|
||||
- les requêtes reçues par le faux DuckDuckGo (chemin, en-têtes, cookies) ;
|
||||
- les pages et recherches demandées au moteur intégré.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- recherche : limites, anti-bot, aucun résultat, panne ;
|
||||
- lecture : plages, bornes, au-delà, page jamais ouverte ;
|
||||
- grep : contexte fusionné, plafond, sans résultat, classes ASCII, sept
|
||||
regex invalides ;
|
||||
- cache : réouverture, forcée, options, la plus récente, péremption ;
|
||||
- GitHub ;
|
||||
- les réponses de Crawl4AI ;
|
||||
- serveur injoignable, sans serveur, sans clé ;
|
||||
- moteur intégré : ouverture, texte brut, page absente, recherche ;
|
||||
- images : doublons, cookie gardé, jeton absent, pannes, réponses
|
||||
illisibles ou mal typées, `null`.
|
||||
|
||||
`tests/webtools.rs` le rejoue contre le port. Le faux Go sert ce que le
|
||||
moteur intégré a tiré des pages.
|
||||
|
||||
D'autres tests complètent le journal :
|
||||
|
||||
- `tests/lot6.rs` (`outil_web_par_la_facade`) : un tour de la façade ouvre
|
||||
une page sans passer par `/tool/exec` ;
|
||||
- `TestInternalMoteurWebIntegre` : les deux appels internes du Go ;
|
||||
- tests unitaires : MD5, clés du cache, URL, entités, nombres, erreurs de
|
||||
regex, réponses de Crawl4AI.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 43 — Jobs d'arrière-plan du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le modèle lance un serveur de dev, un watcher ou un build long
|
||||
avec `bash_bg`. Il en suit la sortie avec `bash_tail`, qui sait aussi
|
||||
l'arrêter. Les deux outils partaient au Go (`/internal/v1/tool/exec`), qui
|
||||
gardait les processus. C'étaient les derniers outils shell du tour encore au
|
||||
Go, `bash` étant porté depuis le lot 7.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/tools/jobs.rs` porte `code_jobs.go`.
|
||||
|
||||
**`bash_bg`** :
|
||||
|
||||
- refus avant tout lancement :
|
||||
- commande vide ;
|
||||
- commande dangereuse (même politique que `bash`) ;
|
||||
- optimisation en cours (`/internal/v1/tune/guard`) ;
|
||||
- journal `.loki/jobs/<id>.log` dans le dossier de la discussion (le
|
||||
contexte des outils du Go) ;
|
||||
- numéros `job1`, `job2`… ;
|
||||
- shell de la plateforme, sans le jeton interne dans l'environnement ;
|
||||
- le processus survit au tour ;
|
||||
- code de sortie du Go : -1 pour un processus tué.
|
||||
|
||||
**`bash_tail`** :
|
||||
|
||||
- 40 lignes par défaut, 200 au plus ; un nombre est tronqué comme
|
||||
`int(float64)` ;
|
||||
- état « en cours » ou « terminé (exit N) » et durée arrondie à la
|
||||
seconde ;
|
||||
- sortie vide signalée ;
|
||||
- fin gardée en caractères ;
|
||||
- octets invalides en U+FFFD un à un ;
|
||||
- `kill:true` arrête le job et relit aussitôt, comme le Go : l'état peut
|
||||
encore dire « en cours ».
|
||||
|
||||
Les erreurs d'écriture ont le texte du Go. `paths::go_mkdir_all` reproduit
|
||||
`os.MkdirAll` : l'erreur nomme l'élément du chemin qui a échoué.
|
||||
|
||||
`ChatShared.jobs` (les jobs du processus) sert les tours :
|
||||
`GoTurnHost::exec_tool` exécute les deux outils. Sans contexte d'outils,
|
||||
`bash_bg` passe au Go comme avant.
|
||||
|
||||
Les jobs d'une discussion supprimée s'arrêtent avec elle
|
||||
(`GoSessionHooks::dropped`, `stopConvJobs`).
|
||||
|
||||
L'optimiseur refuse de mesurer tant qu'un job tourne. Côté Go,
|
||||
`bgJobsRunning` ajoute ceux de la façade quand elle tient la conversation
|
||||
(`POST /internal/facade/v1/bgjobs` → `{running}`) ; façade injoignable :
|
||||
les siens seulement.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_jobs_test.go` (Linux) tire du Go un journal de 24
|
||||
étapes (`tests/parity_jobs/jobs.json`). Chaque étape porte le résultat (pid
|
||||
et durées masqués) et les journaux présents. Il couvre :
|
||||
|
||||
- commande vide, refusée ;
|
||||
- sortie des deux flux, lignes demandées (nulles, en texte, tronquées) ;
|
||||
- arrêt d'un job fini ;
|
||||
- job inconnu, sans id ;
|
||||
- sortie en erreur sans rien d'écrit ;
|
||||
- job long, en cours, arrêté (état relu masqué), puis fini à -1 ;
|
||||
- quarante lignes par défaut, deux cents au plus ;
|
||||
- sortie démesurée en caractères multi-octets ;
|
||||
- octets invalides.
|
||||
|
||||
`tests/jobs.rs` le rejoue contre le port.
|
||||
|
||||
D'autres tests complètent le journal :
|
||||
|
||||
- `tests/lot6.rs` (`job_arriere_plan_par_la_facade`) : un tour de la façade
|
||||
lance un job dans le dossier de la discussion sans passer par le Go ;
|
||||
supprimer une autre discussion n'y touche pas, la sienne l'arrête ;
|
||||
- `anneau_de_la_facade_pour_le_go` : le compte des jobs par l'API interne ;
|
||||
- `TestFacadeJobsArrierePlan` : le Go les ajoute aux siens, façade en panne
|
||||
comprise ;
|
||||
- tests unitaires : refus de l'optimiseur, discussion supprimée, dossier
|
||||
impossible.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Lot 44 — Navigateur piloté dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le contrôle du navigateur donne à l'agent un Chromium local, piloté par le
|
||||
protocole DevTools (CDP). L'agent l'utilise ainsi :
|
||||
|
||||
- il ouvre une page et lit les éléments interactifs numérotés ;
|
||||
- il clique, tape, défile par numéro ;
|
||||
- avec la vision, il voit une capture quadrillée et clique en
|
||||
coordonnées.
|
||||
|
||||
La façade tient la conversation par défaut, mais la session Chrome, les
|
||||
outils `browser_*` et l'anneau des aperçus montrés à l'utilisateur étaient
|
||||
au Go (`/internal/v1/tool/exec`).
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/browser/` porte quatre fichiers Go :
|
||||
|
||||
- `computer_cdp.go` : client CDP, lancement, scripts ;
|
||||
- `computer_use.go` : les neuf outils ;
|
||||
- `browser_grid.go` : la grille de la capture ;
|
||||
- `cu_autoshot.go` : les aperçus.
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **le lancement** :
|
||||
- binaire imposé, celui de Playwright ou du système ;
|
||||
- profil temporaire `loki-cu-*`, et ceux de plus d'une heure balayés ;
|
||||
- mêmes arguments (headless sauf `LOKI_CU_HEADFUL`) ;
|
||||
- tué avec la façade (`PR_SET_PDEATHSIG`) ;
|
||||
- port lu dans `DevToolsActivePort`, cible « page » par `/json` ;
|
||||
- viewport fixe 1280×800 ;
|
||||
- script qui garde les nouveaux onglets dans l'onglet courant ;
|
||||
- **la session** :
|
||||
- une seule, lancée à la demande ;
|
||||
- vérifiée par un ping avant chaque outil et relancée si elle ne répond
|
||||
plus ;
|
||||
- commandes multiplexées par numéro, 30 s au plus ;
|
||||
- requêtes réseau suivies pour attendre le calme (600 ms, 6 s au plus)
|
||||
après un chargement ;
|
||||
- **les scripts** injectés, copiés du Go à l'octet près (le journal
|
||||
compare leurs empreintes) :
|
||||
- éléments actionnables et visibles ;
|
||||
- shadow DOM ;
|
||||
- nom d'un champ par son libellé, sans révéler un mot de passe ;
|
||||
- recherche non destructive ;
|
||||
- liste déroulante native réglée par son option ;
|
||||
- **les outils**, mêmes textes :
|
||||
- snapshot dédoublonné ;
|
||||
- indices de défilement ;
|
||||
- erreurs de navigation (`errorText`, page `chrome-error://`,
|
||||
`ERR_ABORTED` bénin) ;
|
||||
- `back` et `forward` ;
|
||||
- touches nommées ;
|
||||
- plafond de 8000 caractères ;
|
||||
- **la capture** (`browser_screenshot`, vision active) :
|
||||
- PNG propre rangé dans le dossier de travail
|
||||
(`capture-AAAAMMJJ-HHMMSS.png`, heure locale) ;
|
||||
- image du modèle quadrillée tous les 100 px, mêmes pixels que le Go ;
|
||||
à 1280×800 elle est sous la taille maximale et part telle quelle ;
|
||||
- relayée au modèle comme `see_image` ;
|
||||
- **les aperçus** :
|
||||
- après une action qui change la page, jamais après une erreur ;
|
||||
- JPEG en mémoire, anneau de huit ;
|
||||
- servis par `/api/chat/cushot`, que le Go remet désormais à la façade.
|
||||
|
||||
`ChatShared.browser` porte le navigateur du processus ;
|
||||
`GoTurnHost::exec_tool` exécute les outils et prend l'aperçu.
|
||||
|
||||
Un seul navigateur : quand la façade tient la conversation, le Go lui passe
|
||||
ses propres appels (`facade_browser.go`) :
|
||||
|
||||
- outils des sous-agents et des tâches IA ;
|
||||
- aperçus ;
|
||||
- fermeture (interrupteur coupé, ligne de commande).
|
||||
|
||||
Ils passent par `POST /internal/facade/v1/browser`, avec trois opérations :
|
||||
`tool`, `shot`, `shutdown`. Si la façade ne répond pas, le navigateur du Go
|
||||
prend le relais. Couper l'interrupteur côté façade (lot 17) ferme les deux.
|
||||
|
||||
## Écarts connus
|
||||
|
||||
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
|
||||
- Une capture au-delà de 1568 px ne serait pas réduite (le viewport fixe
|
||||
rend le cas impossible).
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_browser_test.go` (Linux) tire du Go un journal de 42
|
||||
étapes (`tests/parity_browser/browser.json`) contre un faux Chrome :
|
||||
|
||||
- un script annonce le port d'un faux serveur CDP ;
|
||||
- le faux CDP répond selon le scénario de chaque étape et note chaque
|
||||
commande.
|
||||
|
||||
Chaque étape porte :
|
||||
|
||||
- le résultat ;
|
||||
- les commandes CDP (méthode, paramètres, scripts résumés par leur
|
||||
empreinte) ;
|
||||
- pour une capture, ses dimensions et dix pixels de la grille ;
|
||||
- les captures rangées.
|
||||
|
||||
Les arguments de lancement sont relevés aussi. Le journal couvre :
|
||||
|
||||
- ouverture, snapshot inchangé, réouverture complète ;
|
||||
- snapshots vide, `null`, sans valeur, illisible, exception JS ;
|
||||
- défilements ;
|
||||
- clics par numéro, introuvable, refusé, en coordonnées ;
|
||||
- saisies libre, dans un champ, liste déroulante, option absente, champ
|
||||
introuvable ;
|
||||
- touches ;
|
||||
- recherches ;
|
||||
- navigations en échec, page d'erreur, interruption bénigne, `back`,
|
||||
`forward`, commande refusée ;
|
||||
- captures sans vision, quadrillée, refusée ;
|
||||
- sortie plafonnée.
|
||||
|
||||
`tests/browser.rs` le rejoue contre le port, puis vérifie l'aperçu (JPEG
|
||||
de l'anneau) et la fermeture (profil retiré).
|
||||
|
||||
`vrai_navigateur` (avec `LOKI_CU_SMOKE=1`, comme `TestCDPSmoke`) pilote un
|
||||
vrai Chromium sur une page locale : ouverture, recherche, saisie, liste
|
||||
déroulante, clic qui navigue, `back`, capture 1280×800, aperçu, navigation
|
||||
en échec.
|
||||
|
||||
D'autres tests complètent :
|
||||
|
||||
- `tests/lot6.rs` (`navigateur_par_la_facade`) : un tour de la façade ouvre
|
||||
une page sans passer par le Go, avec aperçu ; l'API du Go et
|
||||
`/api/chat/cushot` par une façade complète ;
|
||||
- `TestFacadeNavigateur` : le Go passe ses outils, captures, aperçus et
|
||||
fermeture à la façade, et se replie sur son navigateur si elle est en
|
||||
panne ;
|
||||
- tests unitaires : outils, nombres au format du Go, arguments, grille,
|
||||
profils, lancement impossible.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Lot 45 — Outils d'image de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Deux outils de l'agent montrent une image au modèle :
|
||||
|
||||
- `see_image` charge un fichier image du disque dans la vision du
|
||||
modèle ;
|
||||
- `web_screenshot` photographie une page rendue par Playwright, la range
|
||||
dans le dossier de la discussion et donne à l'agent la ligne markdown
|
||||
qui la montre à l'utilisateur.
|
||||
|
||||
La façade tient la conversation par défaut, mais ces deux outils passaient
|
||||
encore par le Go (`/internal/v1/tool/exec`), qui renvoyait l'image à
|
||||
relayer dans `vision_img`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/imagetools.rs` porte trois fichiers Go :
|
||||
|
||||
- `chat_vision_tool.go` (`see_image`) ;
|
||||
- `chat_screenshot.go` (`web_screenshot`) ;
|
||||
- `prepareImageForModel` (`web_upload_orient.go`).
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **la préparation de l'image**, la même que pour les pièces jointes :
|
||||
- orientation EXIF d'une JPEG redressée dans les pixels ;
|
||||
- grand côté ramené sous 1568 px (mêmes pixels que le Go) ;
|
||||
- PNG réencodée en PNG, le reste en JPEG qualité 88 ;
|
||||
- rien à faire, ou image que la bibliothèque standard du Go ne décode
|
||||
pas (WebP, BMP, PNG cassée) : les octets d'origine ;
|
||||
- **la sonde du moteur** (`engineSeesImages`) :
|
||||
- preset externe : sa case « vision » ;
|
||||
- sinon `GET /props` du moteur local, authentifié par la clé de
|
||||
complétion, gardé 10 s ;
|
||||
- appelée en dernier, après toutes les vérifications du fichier ;
|
||||
- **`see_image`**, mêmes textes :
|
||||
- vision coupée, poste distant, chemin manquant ;
|
||||
- format non reconnu, fichier introuvable, dossier ;
|
||||
- trop lourde (12 Mo au plus) ;
|
||||
- moteur aveugle, fichier vide ;
|
||||
- chemin relatif au dossier de travail de l'agent, ou absolu ;
|
||||
- **`web_screenshot`**, mêmes textes :
|
||||
- Playwright absent, url manquante ou invalide ;
|
||||
- internet coupé : seule une adresse locale passe (`localhost`,
|
||||
`127.0.0.1`, `::1`, `0.0.0.0`, `*.localhost`, casse comprise comme
|
||||
le Go) ;
|
||||
- mêmes arguments de Playwright (viewport, page entière, attente,
|
||||
90 s au plus) ;
|
||||
- erreur résumée par ses trois dernières lignes non vides ;
|
||||
- aucune image ou image vide refusée ;
|
||||
- capture rangée dans `captures/` du dossier de la discussion, nommée
|
||||
par l'hôte et l'heure locale ;
|
||||
- ménage : 20 captures et 40 Mo au plus, la nouvelle reste ;
|
||||
- relayée au modèle (« Voici la capture demandée. ») seulement si la
|
||||
vision est active et que le moteur voit les images.
|
||||
|
||||
`ChatShared.vision` porte la sonde du processus ; `GoTurnHost::exec_tool`
|
||||
exécute les deux outils :
|
||||
|
||||
- `see_image` prend le dossier de travail et le poste visé du contexte
|
||||
des outils (lot 7) ; contexte indisponible : le Go prend le relais ;
|
||||
- `web_screenshot` range ses captures dans le dossier de la discussion
|
||||
principale, sous le dossier de travail de l'agent, et les montre par
|
||||
`ToolOutcome.image`.
|
||||
|
||||
Rien ne change côté Go : ces outils n'ont pas d'état partagé, et le Go
|
||||
garde les siens pour ses sous-agents et ses tâches IA.
|
||||
|
||||
## Écarts connus
|
||||
|
||||
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_image_tools_test.go` (Linux) tire du Go un journal
|
||||
(`tests/parity_images/images.json`) avec ses fichiers (en base64), un faux
|
||||
moteur (`/props` authentifié, vision réglée par étape, sondes comptées) et
|
||||
un faux Playwright (script qui note ses arguments et produit l'image, rien,
|
||||
un fichier vide ou une erreur).
|
||||
|
||||
Chaque étape porte :
|
||||
|
||||
- le résultat, avec le texte relayé au modèle ;
|
||||
- l'image relayée :
|
||||
- identique à un fichier : son nom ;
|
||||
- réencodée : son type et ses dimensions ;
|
||||
- en PNG : cinq pixels ;
|
||||
- la capture montrée, les arguments de Playwright, le nombre de sondes ;
|
||||
- le dossier des captures.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- `see_image` :
|
||||
- vision coupée ;
|
||||
- chemin vide, pas une image, introuvable, dossier, trop lourde ;
|
||||
- moteur aveugle, fichier vide ;
|
||||
- onze images : PNG, JPEG, GIF, WebP, BMP, PNG cassée, JPEG tournée,
|
||||
grandes images réduites en largeur et en hauteur, sous-dossier,
|
||||
chemin absolu ;
|
||||
- `web_screenshot` :
|
||||
- url manquante ou invalide, internet coupé ;
|
||||
- capture locale sans vision, capture vue par le modèle ;
|
||||
- page entière réduite ;
|
||||
- vision mais moteur aveugle ;
|
||||
- Playwright en échec, image vide, aucune image ;
|
||||
- ménage des captures.
|
||||
|
||||
`tests/imagetools.rs` le rejoue contre le port. D'autres tests
|
||||
complètent :
|
||||
|
||||
- `tests/lot6.rs` (`outils_image_par_la_facade`) : un tour de la façade
|
||||
appelle les deux outils sans passer par le Go ;
|
||||
- tests unitaires : types d'image, noms de capture, adresses locales,
|
||||
dernières lignes, préparation, ménage, refus avant Playwright.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 46 — Outils recall et recall_search dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La compaction résume les vieux tours d'une discussion. Avant de résumer un
|
||||
gros bloc (un code, une page, un diff), elle l'archive verbatim sous un id
|
||||
court (`r7`) que le résumé cite. L'agent a deux outils pour le retrouver :
|
||||
|
||||
- `recall(id)` rend le bloc exact ;
|
||||
- `recall_search(query)` retrouve un bloc et son id par mots-clés, même
|
||||
sorti du résumé courant.
|
||||
|
||||
La façade compacte elle-même et archive par le Go
|
||||
(`/internal/v1/recall/archive`, lot 6) : l'id vient de la séquence de la
|
||||
base, que seul le Go tient. Les deux outils passaient encore par
|
||||
`/internal/v1/tool/exec`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/recall.rs` porte `toolRecall` et `toolRecallSearch`
|
||||
(`chat_recall.go`) :
|
||||
|
||||
- **l'id tolérant** (`recallIDToSeq`) :
|
||||
- espaces, un préfixe `r` puis un préfixe `R`, espaces encore ;
|
||||
- des chiffres seulement : ni signe, ni dépassement de 64 bits ;
|
||||
- un id mal formé ne lit pas l'archive ;
|
||||
- **les mots-clés** (`recallTokens`) :
|
||||
- mise en minuscules rune par rune, comme `strings.ToLower` (`İ`
|
||||
donne `i`, `Σ` donne `σ`) ;
|
||||
- coupés sur tout ce qui n'est ni `a`–`z`, ni `0`–`9`, ni `à`–`ÿ` ;
|
||||
- deux runes au moins ;
|
||||
- **le score** :
|
||||
- un point par terme présent dans le contenu, deux dans le libellé ;
|
||||
- à égalité, le plus récent d'abord ;
|
||||
- 5 résultats par défaut, 20 au plus, limite tronquée comme
|
||||
`int(float64)` ;
|
||||
- **les textes**, mêmes que le Go :
|
||||
- id ou requête manquants ;
|
||||
- bloc introuvable (id cité par `%q`) ;
|
||||
- bloc rappelé verbatim ;
|
||||
- liste des résultats avec un extrait d'une ligne de 160 runes ;
|
||||
- aucun résultat.
|
||||
|
||||
L'archive reste au Go, qui la sert à la façade :
|
||||
|
||||
- `GET /internal/v1/recall/block?seq=N` : le bloc (200), ou 404 s'il est
|
||||
absent ou chiffré sous une mémoire verrouillée ;
|
||||
- `GET /internal/v1/recall/blocks` : tous les blocs lisibles, par séquence
|
||||
croissante.
|
||||
|
||||
`recallGet` et `recallSearch` du Go passent par les mêmes lectures
|
||||
(`recallGetSeq`, `recallAll`).
|
||||
|
||||
`GoTurnHost::exec_tool` exécute les deux outils avant le repli sur le Go.
|
||||
Si le Go ne répond pas, l'appel passe par lui.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_recall_test.go` archive 35 blocs variés :
|
||||
|
||||
- accents et casse Unicode ;
|
||||
- balises et entités ;
|
||||
- bloc vide ;
|
||||
- premières lignes longues ;
|
||||
- vingt-cinq blocs semblables.
|
||||
|
||||
Il tire ensuite du Go un journal de 41 appels
|
||||
(`tests/parity_recall/recall.json`), avec les blocs tels que l'archive les
|
||||
sert :
|
||||
|
||||
- `recall` :
|
||||
- id absent, non texte, blanc ;
|
||||
- variantes de préfixe et d'espaces ;
|
||||
- zéros de tête ;
|
||||
- mal formé, négatif, signe plus, zéro, absent, démesuré ;
|
||||
- guillemets dans l'id ;
|
||||
- `recall_search` :
|
||||
- requête absente, non texte, blanche ;
|
||||
- mots trop courts ;
|
||||
- casse, accents, `İ`, grec hors des mots ;
|
||||
- souligné coupé ;
|
||||
- extraits bornés ;
|
||||
- limites : une, tronquée, négative, par défaut, au plus, en texte
|
||||
ignorée ;
|
||||
- aucun résultat ;
|
||||
- ponctuation.
|
||||
|
||||
`tests/recall.rs` le rejoue : le faux Go sert les mêmes blocs. Il vérifie
|
||||
aussi le repli quand le Go ne répond pas.
|
||||
|
||||
D'autres tests complètent :
|
||||
|
||||
- `tests/lot6.rs` (`recall_par_la_facade`) : un tour de la façade rappelle
|
||||
et cherche sans passer par `/tool/exec` ;
|
||||
- `TestRecallInterneLecture` : les deux lectures du Go (absent, seq
|
||||
illisible, méthode refusée, ordre) ;
|
||||
- tests unitaires : ids, mots-clés, score, extraits, textes.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Lot 47 — Outil subagent du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le builder peut déléguer une question bornée à un rôle en
|
||||
lecture seule :
|
||||
|
||||
- `explorer` cartographie le code ;
|
||||
- `code-reviewer` relit un changement ;
|
||||
- `planner` découpe un travail et pose les critères d'acceptation.
|
||||
|
||||
Le rôle travaille dans son propre contexte : le prompt du rôle, le
|
||||
briefing machine et la tâche, rien de l'historique. Sa trace n'est ni
|
||||
rangée ni affichée. Seul son texte final revient au builder, comme
|
||||
résultat d'outil. Sur un modèle local, à la fenêtre étroite, c'est ce qui
|
||||
évite de payer dix lectures de fichiers dans le fil principal.
|
||||
|
||||
La façade tient le tour du builder et sa passe de vérification, mais
|
||||
`subagent` passait encore par `/internal/v1/tool/exec` : le Go faisait
|
||||
tourner un `runChat` à lui.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/chat/subagent.rs` porte `toolSubagent` (`code_subagent.go`).
|
||||
|
||||
Avant tout appel au moteur, il fait les mêmes refus que le Go, dans le
|
||||
même ordre, avec les mêmes textes :
|
||||
|
||||
- rôle inconnu (`verifier` compris : seule la boucle du contrat le lance) ;
|
||||
- tâche vide ;
|
||||
- appel depuis un tour qui n'est ni le fil principal ni le builder
|
||||
(garde-fou anti-récursion).
|
||||
|
||||
Le contexte du rôle vient du Go
|
||||
(`POST /internal/v1/turn/subagent {role}`), qui rend :
|
||||
|
||||
- le prompt du rôle suivi du briefing machine ;
|
||||
- les outils du rôle ;
|
||||
- ses capacités (agent, Code, mémoire coupée).
|
||||
|
||||
`subagentView` et `subagentFraming` sont partagés avec `toolSubagent`.
|
||||
|
||||
Le tour du rôle tourne ici avec `run_turn`, comme la passe de
|
||||
vérification :
|
||||
|
||||
- l'endpoint, le raisonnement, l'écho, les motifs de relance et l'arrêt
|
||||
sont ceux du tour qui délègue (`SubTurn`) ;
|
||||
- la température est 0,6 (le TEMP du preset l'emporte) ;
|
||||
- la tâche porte le cadrage du rôle :
|
||||
- planner : critères et étapes, sans demander confirmation ;
|
||||
- autres rôles : seulement les trouvailles, `fichier:ligne` ;
|
||||
- c'est un travail annexe (`engineSideJob`) : second slot, puis slot
|
||||
effacé à la fin pour que le builder reparte du cache RAM ;
|
||||
- la télémétrie le compte à part (`subagent`) ;
|
||||
- les critères posés par le planner s'affichent comme ceux du fil.
|
||||
|
||||
Le rapport se construit ainsi :
|
||||
|
||||
- seul le texte écrit après le dernier outil compte ;
|
||||
- il est rogné de ses blancs, puis préfixé du rôle ;
|
||||
- au-delà de 6000 runes, il est tronqué (`…[tronqué]`) ;
|
||||
- vide : « n'a rien répondu » ;
|
||||
- erreur du tour : « sous-agent <rôle> : <erreur> ».
|
||||
|
||||
Le tour du rôle est porté par `SubHost`. Ce `TurnHost` délègue tout au
|
||||
`GoTurnHost` du rôle, sauf un nouvel appel à `subagent`, toujours refusé.
|
||||
Les outils de la façade sont dans `GoTurnHost::exec_tool_base` ; seul le
|
||||
`exec_tool` du tour principal y ajoute `subagent`. Ainsi le type du tour
|
||||
imbriqué ne se contient pas lui-même.
|
||||
|
||||
Le tour du builder et ses relances par la boucle du contrat prêtent leur
|
||||
`SubTurn`. La vérification aussi, mais son rôle la fait refuser.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_subagent_test.go` tire du Go un journal
|
||||
(`tests/parity_subagent/subagent.json`) contre un faux moteur scripté. Les
|
||||
vues des trois rôles sont rangées avec lui.
|
||||
|
||||
Pour chaque tour délégué, le journal garde chaque requête du rôle :
|
||||
|
||||
- système identique à la vue ;
|
||||
- message de la tâche cadrée ;
|
||||
- température ;
|
||||
- outils ;
|
||||
- nombre de messages.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- les refus :
|
||||
- rôle inconnu, `verifier`, rôle absent ;
|
||||
- tâche vide ou absente ;
|
||||
- récursion depuis un rôle ou depuis la vérification ;
|
||||
- les tours :
|
||||
- réponse directe rognée ;
|
||||
- rapport après deux outils, narration écartée ;
|
||||
- texte vide relancé puis remplacé, comme le fait le tour ;
|
||||
- rien après l'outil ;
|
||||
- rapport tronqué ;
|
||||
- cadrage du planner ;
|
||||
- moteur qui refuse.
|
||||
|
||||
`subagent_comme_le_go` (`tests/lot6.rs`) le rejoue :
|
||||
|
||||
- les refus par `precheck` ;
|
||||
- les tours par un tour complet de la façade qui délègue, contre le même
|
||||
script.
|
||||
|
||||
Il vérifie les requêtes du rôle, le rapport rendu au builder et le travail
|
||||
annexe ouvert puis fermé.
|
||||
|
||||
D'autres tests complètent :
|
||||
|
||||
- `TestSubagentVueInterne` : la vue servie par le Go, rôles refusés ;
|
||||
- tests unitaires : refus et leur ordre, rapports, cadrages.
|
||||
@@ -44,20 +44,45 @@ func emitBuildLine(line string) {
|
||||
// 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)
|
||||
type sinkWriter struct {
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
mu sync.Mutex
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.buf = append(s.buf, p...)
|
||||
for {
|
||||
i := strings.IndexByte(string(s.buf), '\n')
|
||||
i := strings.IndexAny(string(s.buf), "\r\n")
|
||||
if i < 0 {
|
||||
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:]
|
||||
}
|
||||
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
|
||||
// chars, elide the middle. Empty in → empty out.
|
||||
// chars, elide the middle. Empty in → empty out. Une clé d'un ou deux octets
|
||||
// n'est pas montrée du tout : k[len(k)-2:] paniquait sur un seul octet, et
|
||||
// /api/apikey tombait à chaque lecture tant que la clé restait en place.
|
||||
func maskAPIKey(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if len(k) <= 2 {
|
||||
return "…"
|
||||
}
|
||||
if len(k) <= 13 {
|
||||
return "…" + k[len(k)-2:]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Preset externe actif : `loki serve` n'a aucun moteur local à lancer. Il
|
||||
// sortait en erreur « MODEL non défini », que systemd (Restart=on-failure)
|
||||
// relançait en boucle (amont #95).
|
||||
func TestServeExitsCleanlyForExternalPreset(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{extKeyFlag: "1", "PORT": "8080"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdServe(nil); err != nil {
|
||||
t.Fatalf("loki serve sur un preset externe : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Le pré-vol refusait `loki start` / `loki restart` sur un preset externe,
|
||||
// en invitant à réinstaller llama.cpp.
|
||||
func TestPreflightAcceptsExternalPreset(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{extKeyFlag: "1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := preflightEngine(); err != nil {
|
||||
t.Fatalf("pré-vol refusé sur un preset externe : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Strata : le pré-vol vérifie sa configuration à lui, pas BIN/MODEL.
|
||||
func TestPreflightStrataWithoutConfig(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{"ENGINE": "strata"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := preflightEngine()
|
||||
if err == nil {
|
||||
t.Fatal("un preset Strata sans STRATA_CONFIG devrait être refusé")
|
||||
}
|
||||
if want := "STRATA_CONFIG"; !contains(err.Error(), want) {
|
||||
t.Fatalf("message attendu sur %s : %v", want, err)
|
||||
}
|
||||
}
|
||||
@@ -282,10 +282,96 @@ type dlState struct {
|
||||
Part int `json:"part"` // tranche en cours (1-based)
|
||||
Finished bool `json:"finished"`
|
||||
Canceled bool `json:"canceled"`
|
||||
Paused bool `json:"paused"`
|
||||
Err string `json:"error"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
|
||||
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
|
||||
gate *dlGate `json:"-"`
|
||||
}
|
||||
|
||||
// dlGate met un téléchargement en pause (repris d'AJEAN v0.18.1) : les
|
||||
// connexions en cours sont coupées (rien ne reste ouvert vers le CDN pendant la
|
||||
// pause), et chaque morceau attend la reprise puis repart de l'octet où il
|
||||
// s'était arrêté, par une requête de plage.
|
||||
type dlGate struct {
|
||||
mu sync.Mutex
|
||||
paused bool
|
||||
wake chan struct{} // fermé à la reprise
|
||||
attempt map[int]context.CancelFunc // requêtes en cours, coupées par la pause
|
||||
next int
|
||||
}
|
||||
|
||||
func newDlGate() *dlGate { return &dlGate{attempt: map[int]context.CancelFunc{}} }
|
||||
|
||||
func (g *dlGate) setPaused(p bool) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p == g.paused {
|
||||
return
|
||||
}
|
||||
g.paused = p
|
||||
if p {
|
||||
g.wake = make(chan struct{})
|
||||
for _, c := range g.attempt {
|
||||
c()
|
||||
}
|
||||
return
|
||||
}
|
||||
close(g.wake)
|
||||
}
|
||||
|
||||
// wait bloque tant que le téléchargement est en pause. false = annulé entre-temps.
|
||||
func (g *dlGate) wait(ctx context.Context) bool {
|
||||
if g == nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
paused, wake := g.paused, g.wake
|
||||
g.mu.Unlock()
|
||||
if !paused {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
select {
|
||||
case <-wake:
|
||||
return ctx.Err() == nil
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (g *dlGate) isPaused() bool {
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.paused
|
||||
}
|
||||
|
||||
// begin ouvre une requête que la pause pourra couper ; la fonction rendue la referme.
|
||||
func (g *dlGate) begin(ctx context.Context) (context.Context, func()) {
|
||||
if g == nil {
|
||||
return ctx, func() {}
|
||||
}
|
||||
actx, cancel := context.WithCancel(ctx)
|
||||
g.mu.Lock()
|
||||
id := g.next
|
||||
g.next++
|
||||
g.attempt[id] = cancel
|
||||
if g.paused {
|
||||
cancel() // pause tombée entre wait et begin
|
||||
}
|
||||
g.mu.Unlock()
|
||||
return actx, func() {
|
||||
g.mu.Lock()
|
||||
delete(g.attempt, id)
|
||||
g.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -429,7 +515,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
st := &dlState{
|
||||
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, gate: newDlGate(),
|
||||
}
|
||||
dlDownloads[name] = st
|
||||
dlMu.Unlock()
|
||||
@@ -759,7 +845,9 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
|
||||
dt := now.Sub(lastAt).Seconds()
|
||||
dlMu.Lock()
|
||||
st.Done = cur
|
||||
if dt > 0 {
|
||||
if st.Paused {
|
||||
st.Speed = 0
|
||||
} else if dt > 0 {
|
||||
inst := int64(float64(cur-last) / dt)
|
||||
if st.Speed == 0 {
|
||||
st.Speed = inst
|
||||
@@ -827,7 +915,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
|
||||
if err := dlFetch(ctx, st.gate, f, dlURL, total, conns, done); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -845,9 +933,9 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
|
||||
// dlFetch writes the whole body into f, either as one stream or as `conns`
|
||||
// parallel byte ranges. done is incremented atomically as bytes land on disk.
|
||||
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
if conns <= 1 {
|
||||
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
|
||||
return dlChunk(ctx, g, f, dlURL, 0, total-1, total <= 0, done)
|
||||
}
|
||||
size := total / int64(conns)
|
||||
var wg sync.WaitGroup
|
||||
@@ -861,7 +949,7 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
wg.Add(1)
|
||||
go func(i int, start, end int64) {
|
||||
defer wg.Done()
|
||||
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
|
||||
errs[i] = dlChunk(ctx, g, f, dlURL, start, end, false, done)
|
||||
}(i, start, end)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -876,11 +964,15 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
// dlChunk downloads [start,end] into f at the right offset, retrying from where
|
||||
// it stopped if the connection drops mid-chunk. With whole=true it streams the
|
||||
// entire body sequentially (server without range support, unknown size).
|
||||
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
const attempts = 4
|
||||
pos := start
|
||||
var lastErr error
|
||||
for try := 0; try < attempts; try++ {
|
||||
// Une pause n'est pas un échec : on attend la reprise sans consommer d'essai.
|
||||
if !g.wait(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err // cancelled: never retry
|
||||
}
|
||||
@@ -900,18 +992,26 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
} else if pos > start {
|
||||
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
|
||||
}
|
||||
req, err := dlRequest(ctx, dlURL, rng)
|
||||
actx, release := g.begin(ctx)
|
||||
req, err := dlRequest(actx, dlURL, rng)
|
||||
if err != nil {
|
||||
release()
|
||||
return err
|
||||
}
|
||||
resp, err := dlClient.Do(req)
|
||||
if err != nil {
|
||||
release()
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try--
|
||||
continue
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||
err := dlSourceError(resp, dlURL)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == 200 && pos > start {
|
||||
@@ -921,10 +1021,15 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
}
|
||||
n, cerr := dlCopy(f, resp.Body, pos, done)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
pos += n
|
||||
if cerr == nil {
|
||||
return nil
|
||||
}
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try-- // coupé par la pause : la reprise repart de pos
|
||||
continue
|
||||
}
|
||||
lastErr = cerr
|
||||
}
|
||||
return lastErr
|
||||
@@ -981,6 +1086,35 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleModelDownloadPause met en pause ({"pause":true}) ou reprend
|
||||
// ({"pause":false}) un téléchargement en cours. Le fichier partiel est gardé.
|
||||
func handleModelDownloadPause(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
Pause bool `json:"pause"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := filepath.Base(strings.TrimSpace(req.Filename))
|
||||
dlMu.Lock()
|
||||
st, ok := dlDownloads[name]
|
||||
if !ok || st.Finished {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement en cours pour " + name})
|
||||
return
|
||||
}
|
||||
st.Paused = req.Pause
|
||||
if req.Pause {
|
||||
st.Speed = 0
|
||||
}
|
||||
g := st.gate
|
||||
dlMu.Unlock()
|
||||
g.setPaused(req.Pause)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "paused": req.Pause})
|
||||
}
|
||||
|
||||
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
|
||||
// startup. A download killed by a crash or a service restart can't be resumed
|
||||
// (its state lived in memory), so the partial file would otherwise sit there
|
||||
|
||||
@@ -231,3 +231,96 @@ func TestDLProbeSurfacesExplainedError(t *testing.T) {
|
||||
t.Errorf("message %q sans indication sur le jeton", err)
|
||||
}
|
||||
}
|
||||
|
||||
type slowWriter struct{ http.ResponseWriter }
|
||||
|
||||
func (s slowWriter) Write(b []byte) (int, error) {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
return s.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Une pause coupe le flux, garde ce qui est écrit, et la reprise termine un
|
||||
// fichier identique à la source (repris d'AJEAN v0.18.1).
|
||||
func TestRunDownloadPauseResume(t *testing.T) {
|
||||
data := make([]byte, 48<<20)
|
||||
rand.New(rand.NewSource(2)).Read(data)
|
||||
t.Setenv("LOKI_DL_CONNS", "4")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(slowWriter{w}, r, "m.gguf", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "m.gguf")
|
||||
st := &dlState{Filename: "m.gguf", gate: newDlGate()}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
st.gate.setPaused(true)
|
||||
time.Sleep(1500 * time.Millisecond) // le compteur publié se fige
|
||||
dlMu.Lock()
|
||||
a := st.Done
|
||||
dlMu.Unlock()
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
dlMu.Lock()
|
||||
b, fin := st.Done, st.Finished
|
||||
dlMu.Unlock()
|
||||
if fin || a != b || a == 0 {
|
||||
t.Fatalf("pause inopérante : %d puis %d octets, terminé=%v", a, b, fin)
|
||||
}
|
||||
st.gate.setPaused(false)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("pas terminé après la reprise")
|
||||
}
|
||||
if st.Err != "" {
|
||||
t.Fatalf("erreur après reprise : %s", st.Err)
|
||||
}
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("fichier différent de la source après reprise (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// La route : pause puis reprise d'un téléchargement connu, refus pour
|
||||
// l'inconnu et pour un téléchargement terminé.
|
||||
func TestHandleModelDownloadPause(t *testing.T) {
|
||||
dlMu.Lock()
|
||||
saved := dlDownloads
|
||||
dlDownloads = map[string]*dlState{
|
||||
"a.gguf": {Filename: "a.gguf", Speed: 9, gate: newDlGate()},
|
||||
"b.gguf": {Filename: "b.gguf", Finished: true},
|
||||
}
|
||||
dlMu.Unlock()
|
||||
t.Cleanup(func() { dlMu.Lock(); dlDownloads = saved; dlMu.Unlock() })
|
||||
post := func(body string) (int, string) {
|
||||
w := httptest.NewRecorder()
|
||||
handleModelDownloadPause(w, httptest.NewRequest("POST", "/api/models/download/pause", strings.NewReader(body)))
|
||||
return w.Code, strings.TrimSpace(w.Body.String())
|
||||
}
|
||||
if code, out := post(`{"filename":"../a.gguf","pause":true}`); code != 200 || out != `{"ok":true,"paused":true}` {
|
||||
t.Fatalf("pause : %d %s", code, out)
|
||||
}
|
||||
dlMu.Lock()
|
||||
st := dlDownloads["a.gguf"]
|
||||
paused, speed := st.Paused, st.Speed
|
||||
dlMu.Unlock()
|
||||
if !paused || speed != 0 || !st.gate.isPaused() {
|
||||
t.Errorf("état après pause : paused=%v speed=%d porte=%v", paused, speed, st.gate.isPaused())
|
||||
}
|
||||
if code, out := post(`{"filename":"a.gguf","pause":false}`); code != 200 || out != `{"ok":true,"paused":false}` || st.gate.isPaused() {
|
||||
t.Fatalf("reprise : %d %s", code, out)
|
||||
}
|
||||
for _, body := range []string{`{"filename":"b.gguf","pause":true}`, `{"filename":"x.gguf","pause":true}`} {
|
||||
if code, _ := post(body); code != 404 {
|
||||
t.Errorf("%s : %d, attendu 404", body, code)
|
||||
}
|
||||
}
|
||||
if code, _ := post(`[`); code != 400 {
|
||||
t.Errorf("corps illisible : %d", code)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
|
||||
// llamaReleasesAPI : variable pour que les tests la pointent sur un faux GitHub.
|
||||
var llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
|
||||
|
||||
type ghAsset struct {
|
||||
Name string `json:"name"`
|
||||
@@ -41,18 +42,25 @@ func prebuiltDir() string {
|
||||
return filepath.Join(backendsDir(), "llama.cpp-prebuilt")
|
||||
}
|
||||
|
||||
// prebuiltVersionFields : les champs du marqueur VERSION, « tag cudaVer
|
||||
// format ». La version CUDA est vide hors CUDA Windows : « b300 fmt2 » n'a
|
||||
// alors que DEUX champs, et le format est le second.
|
||||
func prebuiltVersionFields() []string {
|
||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
|
||||
// (cudaVer vide hors CUDA Windows).
|
||||
func prebuiltVersion() (tag, cudaVer string) {
|
||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
f := strings.Fields(strings.TrimSpace(string(b)))
|
||||
f := prebuiltVersionFields()
|
||||
if len(f) > 0 {
|
||||
tag = f[0]
|
||||
}
|
||||
if len(f) > 1 {
|
||||
if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") {
|
||||
cudaVer = f[1]
|
||||
}
|
||||
return
|
||||
@@ -64,15 +72,13 @@ func prebuiltVersion() (tag, cudaVer string) {
|
||||
// sera refaite au lieu d'être déclarée « déjà à jour ».
|
||||
const prebuiltFormat = "fmt2"
|
||||
|
||||
// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
|
||||
// prebuiltVersionFormat lit le format, dernier champ du marqueur VERSION (""
|
||||
// si absent). Lu jadis comme 3e champ, il manquait dès que la version CUDA
|
||||
// était vide (Linux, macOS) : chaque vérification réinstallait la release.
|
||||
func prebuiltVersionFormat() string {
|
||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
f := strings.Fields(strings.TrimSpace(string(b)))
|
||||
if len(f) > 2 {
|
||||
return f[2]
|
||||
f := prebuiltVersionFields()
|
||||
if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") {
|
||||
return f[len(f)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
return applyPresetOrder(out), nil
|
||||
}
|
||||
|
||||
// presetOrderKey : ordre d'affichage choisi par glisser-déposer dans l'UI
|
||||
// (repris d'AJEAN v0.10.9), une liste d'IDs persistée en base.
|
||||
const presetOrderKey = "preset_order"
|
||||
|
||||
func loadPresetOrder() []string {
|
||||
var ids []string
|
||||
getJSON(bkState, presetOrderKey, &ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func savePresetOrder(ids []string) error { return putJSON(bkState, presetOrderKey, ids) }
|
||||
|
||||
// applyPresetOrder réordonne `list` (déjà trié par nom) selon l'ordre stocké :
|
||||
// d'abord les presets classés dans l'ordre choisi, puis les non classés
|
||||
// (fraîchement créés) dans leur ordre alphabétique. Tri STABLE.
|
||||
func applyPresetOrder(list []Preset) []Preset {
|
||||
order := loadPresetOrder()
|
||||
if len(order) == 0 {
|
||||
return list
|
||||
}
|
||||
pos := map[string]int{}
|
||||
for i, id := range order {
|
||||
pos[id] = i
|
||||
}
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
pi, oki := pos[list[i].ID]
|
||||
pj, okj := pos[list[j].ID]
|
||||
if oki && okj {
|
||||
return pi < pj
|
||||
}
|
||||
if oki != okj {
|
||||
return oki // un preset classé passe avant un non classé
|
||||
}
|
||||
return false // deux non classés : l'ordre alphabétique reste
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
// presetCtx : la taille de contexte d'un preset, formatée pour la pastille de
|
||||
// la liste (« 32K », « 128K », « 1M »). CTX absent = 32768, le défaut de serve.
|
||||
func presetCtx(content string) string {
|
||||
raw := strings.TrimSpace(parseEnv(content)["CTX"])
|
||||
if raw == "" {
|
||||
raw = "32768"
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return ""
|
||||
}
|
||||
// Multiple de 1024 (32768, 131072…) : écrit comme on le pense, « 32K »,
|
||||
// « 128K » ; sinon décimal, comme on le tape (40000 → « 40K »).
|
||||
switch {
|
||||
case n >= 1024 && n%1024 == 0 && n < 1<<20:
|
||||
return strconv.Itoa(n/1024) + "K"
|
||||
case n >= 1000000:
|
||||
return strings.TrimSuffix(strconv.FormatFloat(float64(n)/1000000, 'f', 1, 64), ".0") + "M"
|
||||
case n >= 1000:
|
||||
return strconv.Itoa((n+500)/1000) + "K"
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// uniquePresetID derives a unique filename id from a display name, appending
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet
|
||||
// ordre ; les nouveaux (non classés) ensuite, par nom.
|
||||
func TestPresetOrder(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
for _, id := range []string{"alpha", "bravo", "charlie", "delta"} {
|
||||
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ids := func() []string {
|
||||
list, err := ListPresets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for _, p := range list {
|
||||
out = append(out, p.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := ids(); got[0] != "alpha" || got[3] != "delta" {
|
||||
t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got)
|
||||
}
|
||||
if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"charlie", "alpha", "bravo", "delta"}
|
||||
got := ids()
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("ordre %v, attendu %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetCtx(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "32K", // défaut de serve : 32768
|
||||
"CTX=40000\n": "40K",
|
||||
"CTX=\"131072\"\n": "128K",
|
||||
"CTX=1000000\n": "1M",
|
||||
"CTX=1500000\n": "1.5M",
|
||||
"CTX=512\n": "512",
|
||||
"CTX=abc\n": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := presetCtx(in); got != want {
|
||||
t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,20 @@ func cmdServe(args []string) error {
|
||||
}
|
||||
cfg = c
|
||||
}
|
||||
// API externe : aucun moteur local. On sort SANS erreur, sinon systemd
|
||||
// (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95).
|
||||
if isExternalConfig(cfg) {
|
||||
fmt.Println("[info] preset externe actif : pas de moteur local à lancer")
|
||||
return nil
|
||||
}
|
||||
// Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son
|
||||
// propre serveur. L'optimiseur ne règle que llama-server.
|
||||
if isStrataConfig(cfg) {
|
||||
if trialDir != "" {
|
||||
return fmt.Errorf("l'optimiseur ne s'applique pas à un preset Strata")
|
||||
}
|
||||
return serveStrata(cfg)
|
||||
}
|
||||
bin := cfg["BIN"]
|
||||
if bin == "" {
|
||||
return fmt.Errorf("BIN non défini — lance « loki edit »")
|
||||
@@ -361,6 +375,10 @@ func cmdServe(args []string) error {
|
||||
probeSideSlotEnv(cfg, &si)
|
||||
// SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre.
|
||||
probeSplit(cfg, bin, &si)
|
||||
// LOAD_GUARD : un refus envisagé sur la VRAM NVIDIA se décide sur les
|
||||
// cartes du moteur (Vulkan sur cartes mixtes, ROCm…). Sinon rien n'est lu.
|
||||
normExtra, _ := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
|
||||
probeLoadDevices(cfg, normExtra, bin, &si)
|
||||
spec := specMode(cfg)
|
||||
if spec != "off" && spec != "" {
|
||||
probeSpec(cfg, &si)
|
||||
@@ -410,7 +428,7 @@ func cmdServe(args []string) error {
|
||||
// Chargement résident avec plus de poids en RAM que la machine n'en a :
|
||||
// échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre.
|
||||
if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" {
|
||||
return fmt.Errorf("%s", refuse)
|
||||
return loadGuardRefusal(refuse, trialDir != "")
|
||||
}
|
||||
|
||||
// Essai : écoute sur 127.0.0.1 et le port libre choisi par l'optimiseur,
|
||||
@@ -459,6 +477,10 @@ func cmdServe(args []string) error {
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), port)
|
||||
// Ligne de commande réellement lancée, gardée pour l'interface (amont #108).
|
||||
if trialDir == "" {
|
||||
recordEngineCmdline(env, llmArgs)
|
||||
}
|
||||
|
||||
// Hand off to the llama-server process. On Unix this replaces the current
|
||||
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
|
||||
@@ -489,7 +511,11 @@ type serveSysInfo struct {
|
||||
RAMAvailMiB int64 // RAM libre au lancement
|
||||
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
|
||||
UnifiedMem bool // macOS : RAM et VRAM ne font qu'une (voir loadModeRisk)
|
||||
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
|
||||
// VRAM des cartes listées par le moteur lui-même (--list-devices), lue
|
||||
// seulement quand LOAD_GUARD envisage un refus (probeLoadDevices) ; 0 =
|
||||
// non lue : un avis au plus, jamais de refus sur la seule VRAM NVIDIA.
|
||||
EngineVRAMMiB int64
|
||||
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
|
||||
|
||||
// Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un
|
||||
// modèle hybride ou SPEC=auto ; 0 = inconnu ou non lu : ni avis ni drapeau
|
||||
@@ -501,6 +527,7 @@ type serveSysInfo struct {
|
||||
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
|
||||
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
||||
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
|
||||
|
||||
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -121,17 +122,54 @@ func cgroupMemMiB(fsys fs.FS) (limit, current int64) {
|
||||
// nvidiaVRAMMiB : VRAM totale des cartes que llama-server verra. Borné dans le
|
||||
// temps comme nvidiaGPUCount ; au moindre doute, 0.
|
||||
func nvidiaVRAMMiB(cvd string, pciOrder bool, timeout time.Duration) int64 {
|
||||
if !hasTool("nvidia-smi") {
|
||||
out, ok := smiTotals.get(time.Now, timeout)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return vramFromSMI(out, cvd, pciOrder)
|
||||
}
|
||||
|
||||
// nvidiaTotalsQuery : la lecture brute des mémoires totales. Variable pour
|
||||
// les tests.
|
||||
var nvidiaTotalsQuery = func(timeout time.Duration) (string, error) {
|
||||
if !hasTool("nvidia-smi") {
|
||||
return "", exec.ErrNotFound
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.total",
|
||||
"--format=csv,noheader,nounits")).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// smiTotalsCache : la lecture des mémoires totales, partagée quelques
|
||||
// secondes. L'éditeur de preset demande à l'ouverture la taille du cache de
|
||||
// prompts (et celle du second slot) et les avis MoE, en parallèle : chacun
|
||||
// lançait SON nvidia-smi pour une réponse identique — la mémoire totale d'une
|
||||
// carte ne bouge pas. Le verrou tenu pendant la lecture fait attendre les
|
||||
// appels simultanés sur la même ; la sélection des cartes (CUDA_VISIBLE_DEVICES)
|
||||
// s'applique après, sur la sortie brute. Un échec est gardé aussi, le temps du
|
||||
// délai : la même salve ne relance pas trois lectures qui échoueront.
|
||||
type smiTotalsCache struct {
|
||||
mu sync.Mutex
|
||||
exp time.Time
|
||||
out string
|
||||
ok bool
|
||||
}
|
||||
|
||||
const smiTotalsTTL = 10 * time.Second
|
||||
|
||||
var smiTotals smiTotalsCache
|
||||
|
||||
func (c *smiTotalsCache) get(now func() time.Time, timeout time.Duration) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if now().Before(c.exp) {
|
||||
return c.out, c.ok
|
||||
}
|
||||
return vramFromSMI(string(out), cvd, pciOrder)
|
||||
out, err := nvidiaTotalsQuery(timeout)
|
||||
c.out, c.ok, c.exp = out, err == nil, now().Add(smiTotalsTTL)
|
||||
return c.out, c.ok
|
||||
}
|
||||
|
||||
// vramFromSMI additionne la VRAM des cartes sélectionnées par
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
const helpCacheRAM = helpRecent + `-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)
|
||||
@@ -262,3 +266,54 @@ func TestPrepareSlotDir(t *testing.T) {
|
||||
t.Fatalf("slots est un fichier : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// L'éditeur demande en parallèle le cache de prompts, le second slot et les
|
||||
// avis MoE : une seule lecture nvidia-smi pour tous, la sélection des cartes
|
||||
// appliquée ensuite. Après le délai, une lecture neuve ; un échec n'est pas
|
||||
// relancé dans la même salve.
|
||||
func TestNvidiaVRAMShared(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
fail := false
|
||||
prevQ := nvidiaTotalsQuery
|
||||
nvidiaTotalsQuery = func(time.Duration) (string, error) {
|
||||
calls.Add(1)
|
||||
time.Sleep(20 * time.Millisecond) // les appels simultanés attendent la même lecture
|
||||
if fail {
|
||||
return "", errors.New("exit status 9")
|
||||
}
|
||||
return "0, 16311\n1, 12288\n", nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
nvidiaTotalsQuery = prevQ
|
||||
smiTotals.mu.Lock()
|
||||
smiTotals.exp = time.Time{} // les tests suivants relisent nvidia-smi
|
||||
smiTotals.mu.Unlock()
|
||||
})
|
||||
expire := func() {
|
||||
smiTotals.mu.Lock()
|
||||
smiTotals.exp = time.Time{}
|
||||
smiTotals.mu.Unlock()
|
||||
}
|
||||
expire()
|
||||
var wg sync.WaitGroup
|
||||
got := make([]int64, 3)
|
||||
for i, cvd := range []string{"", "0", "1"} {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); got[i] = nvidiaVRAMMiB(cvd, true, time.Second) }()
|
||||
}
|
||||
wg.Wait()
|
||||
if n := calls.Load(); n != 1 {
|
||||
t.Errorf("%d lectures nvidia-smi pour trois demandes simultanées", n)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []int64{16311 + 12288, 16311, 12288}) {
|
||||
t.Errorf("sélection des cartes : %v", got)
|
||||
}
|
||||
expire()
|
||||
fail = true
|
||||
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 {
|
||||
t.Errorf("échec : %d, attendu 0", v)
|
||||
}
|
||||
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 || calls.Load() != 2 {
|
||||
t.Errorf("échec relancé dans la même salve : %d lectures", calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,53 @@ func loadGuardOff(cfg map[string]string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// loadGuardExitStatus : le code de sortie de « loki serve » quand LOAD_GUARD
|
||||
// refuse. Le refus est certain et ne dépend que du preset et de la machine :
|
||||
// relancer ne changerait rien. Sous systemd, Restart=on-failure relançait
|
||||
// pourtant toutes les trois secondes, sans fin ; l'unité écrite par
|
||||
// « loki install » porte donc RestartPreventExitStatus pour ce code-là
|
||||
// (EX_CONFIG de sysexits.h : erreur de configuration). Le conteneur, lui, ne
|
||||
// relance jamais le moteur de lui-même.
|
||||
const loadGuardExitStatus = 78
|
||||
|
||||
// unitPreventsRefusalRestart : l'unité systemd (son texte) s'abstient-elle de
|
||||
// relancer sur loadGuardExitStatus ? Une unité écrite par une version
|
||||
// d'avant ne le fait pas.
|
||||
func unitPreventsRefusalRestart(unit string) bool {
|
||||
for _, line := range strings.Split(unit, "\n") {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok || strings.TrimSpace(k) != "RestartPreventExitStatus" {
|
||||
continue
|
||||
}
|
||||
for _, f := range strings.Fields(v) {
|
||||
if f == strconv.Itoa(loadGuardExitStatus) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loadGuardRefusal : l'erreur rendue par cmdServe pour un refus, avec son code
|
||||
// de sortie. Un superviseur qui relancerait quand même sur ce code (unité
|
||||
// systemd d'une version d'avant, launchd, qui relance toute sortie non nulle)
|
||||
// reçoit 0 : une sortie « propre » n'est pas relancée, et la raison reste en
|
||||
// clair dans le journal, où l'interface la reconnaît (modelLoadError). Le
|
||||
// moteur d'essai de l'optimiseur garde le vrai code : c'est lui qui dit
|
||||
// l'échec à « loki tune ».
|
||||
func loadGuardRefusal(refuse string, trial bool) error {
|
||||
code := loadGuardExitStatus
|
||||
if !trial {
|
||||
if restarts, hint := supervisorRestartsRefusal(); restarts {
|
||||
code = 0
|
||||
if hint != "" {
|
||||
fmt.Fprintln(os.Stderr, "[loki serve] "+hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &exitStatusError{code: code, err: fmt.Errorf("%s", refuse)}
|
||||
}
|
||||
|
||||
// loadModeRisk : un mode de chargement résident avec trop de poids en RAM.
|
||||
// Fonction pure ; args est la ligne finale (ou EXTRA_ARGS normalisé).
|
||||
//
|
||||
@@ -257,7 +304,8 @@ func loadGuardOff(cfg map[string]string) bool {
|
||||
// - warn : l'estimation dépasse 80 % — on prévient, on lance.
|
||||
//
|
||||
// Un refus fait sortir « loki serve » en erreur, comme un modèle introuvable :
|
||||
// mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau.
|
||||
// mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau. Le code
|
||||
// de sortie (loadGuardRefusal) empêche le superviseur de relancer en boucle.
|
||||
func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, refuse string) {
|
||||
if isExternalConfig(cfg) {
|
||||
return "", ""
|
||||
@@ -277,20 +325,32 @@ func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn,
|
||||
// Serveurs RPC : une part des poids part sur d'autres machines, la VRAM
|
||||
// locale ne borne plus rien — inconnu, donc un avis au plus, jamais un refus.
|
||||
remote := hasAnyFlag(args, "--rpc") || strings.TrimSpace(si.ArgEnv["LLAMA_ARG_RPC"]) != ""
|
||||
low, est, upTo := int64(0), modelMiB, true
|
||||
low, est, upTo, unsure := int64(0), modelMiB, true, false
|
||||
switch {
|
||||
case remote:
|
||||
case si.UnifiedMem:
|
||||
low, est, upTo = modelMiB, modelMiB, false
|
||||
case si.VRAMMiB > 0:
|
||||
low = max(modelMiB-si.VRAMMiB, 0)
|
||||
case si.EngineVRAMMiB > 0:
|
||||
// Les cartes que le moteur liste lui-même : rien au-delà ne peut
|
||||
// recevoir de poids, la borne basse est sûre.
|
||||
low = max(modelMiB-si.EngineVRAMMiB, 0)
|
||||
est, upTo = low, false
|
||||
case si.VRAMMiB > 0:
|
||||
// nvidia-smi seulement : un moteur Vulkan sur cartes mixtes NVIDIA +
|
||||
// AMD en utilise davantage, un moteur ROCm d'autres. Une estimation,
|
||||
// jamais une borne : un avis au plus (voir probeLoadDevices).
|
||||
est, upTo, unsure = max(modelMiB-si.VRAMMiB, 0), false, true
|
||||
}
|
||||
ram := si.RAMMiB
|
||||
what := "environ "
|
||||
if upTo {
|
||||
what = "jusqu'à "
|
||||
}
|
||||
if unsure && est*10 > ram*9 && !loadGuardOff(cfg) {
|
||||
return fmt.Sprintf("%s : environ %s de poids en RAM (modèle %s, VRAM NVIDIA %s) pour %s — échec probable ; "+
|
||||
"refus seulement si les cartes listées par le moteur le confirment, lancement sinon ; "+
|
||||
"--load-mode mmap est plus sûr", mode, gibText(est), gibText(modelMiB), gibText(si.VRAMMiB), gibText(ram)), ""
|
||||
}
|
||||
if low*10 > ram*9 {
|
||||
msg := fmt.Sprintf("%s : au moins %s de poids restent en RAM (modèle %s) pour %s de RAM, limite du conteneur "+
|
||||
"comprise — échec certain : swap et décodage effondré, ou processus tué (llama.cpp #26110). "+
|
||||
@@ -307,6 +367,50 @@ func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn,
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// loadGuardNeedsDevices : un refus se profile sur la seule VRAM NVIDIA
|
||||
// (nvidia-smi) ? Il ne sera prononcé que sur les cartes du moteur lui-même —
|
||||
// à lire d'abord (probeLoadDevices). Fonction pure.
|
||||
func loadGuardNeedsDevices(cfg map[string]string, args []string, si serveSysInfo) bool {
|
||||
if si.EngineVRAMMiB > 0 || si.VRAMMiB <= 0 {
|
||||
return false
|
||||
}
|
||||
si.EngineVRAMMiB = si.VRAMMiB
|
||||
_, refuse := loadModeRisk(cfg, args, si)
|
||||
return refuse != ""
|
||||
}
|
||||
|
||||
// engineDevsVRAM : la VRAM des cartes listées par le moteur, toutes
|
||||
// additionnées — sans --device, llama.cpp ne place rien ailleurs, et une carte
|
||||
// qu'il listerait sans s'en servir (iGPU) ne fait que rendre le refus plus
|
||||
// rare. 0 (inconnu) si la liste est vide ou si une carte annonce 0 Mio (déjà
|
||||
// pleine : lecture transitoire, voir handleBackendDevices).
|
||||
func engineDevsVRAM(devs []splitDev) int64 {
|
||||
var total int64
|
||||
for _, d := range devs {
|
||||
if d.TotalMiB <= 0 {
|
||||
return 0
|
||||
}
|
||||
total += d.TotalMiB
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// probeLoadDevices : seulement quand loadGuardNeedsDevices — un mode de
|
||||
// chargement résident qu'on s'apprête à refuser. Les cartes de SPLIT_MODE si
|
||||
// elles sont déjà lues, sinon --list-devices dans l'environnement du
|
||||
// lancement (sélection GPU comprise). Échec de lecture : EngineVRAMMiB reste
|
||||
// à 0, loadModeRisk avertit sans refuser. args : EXTRA_ARGS normalisé.
|
||||
func probeLoadDevices(cfg map[string]string, args []string, bin string, si *serveSysInfo) {
|
||||
if !loadGuardNeedsDevices(cfg, args, *si) {
|
||||
return
|
||||
}
|
||||
devs := si.SplitDevs
|
||||
if len(devs) == 0 {
|
||||
devs = listEngineDevices(bin)
|
||||
}
|
||||
si.EngineVRAMMiB = engineDevsVRAM(devs)
|
||||
}
|
||||
|
||||
// previewServeHelp : l'aide supposée d'un moteur récent, pour les aperçus de
|
||||
// l'éditeur (le process web ne lance pas le moteur pour lire son aide). Le
|
||||
// lancement, lui, décide sur la vraie aide.
|
||||
@@ -336,6 +440,14 @@ func moePreview(content string) (notes []string, load string) {
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
norm, _ := normalizeLoadFlags(extra, true)
|
||||
// Les cartes du moteur, sans le lancer : la dernière liste complète qu'il a
|
||||
// donnée à l'éditeur (devices.json). Absente : un avis, pas un refus.
|
||||
if loadGuardNeedsDevices(cfg, norm, si) {
|
||||
bin := prebuiltResolveBin(firstNonEmpty(cfg["BIN"], ReadConfig()["BIN"]))
|
||||
if list, ok := devPersistGet(bin + "\x00" + cfg["CUDA_VISIBLE_DEVICES"]); ok {
|
||||
si.EngineVRAMMiB = engineDevsVRAM(splitDevsFrom(list))
|
||||
}
|
||||
}
|
||||
warn, refuse := loadModeRisk(cfg, norm, si)
|
||||
if refuse != "" {
|
||||
warn = "refusé au lancement — " + refuse
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -109,10 +111,10 @@ func TestLoadModeRisk(t *testing.T) {
|
||||
{name: "sans drapeau : rien",
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
|
||||
{name: "none, 66 Gio certains en RAM pour 60 : refus (#26110)",
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
|
||||
{name: "même chose, LOAD_GUARD=off : avertissement seulement",
|
||||
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
|
||||
{name: "mlock, 54 Gio en RAM pour 64 : avertissement",
|
||||
args: "--load-mode mlock", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
|
||||
{name: "dense tout en VRAM sur une machine à peu de RAM : rien (pas de faux positif)",
|
||||
@@ -122,11 +124,11 @@ func TestLoadModeRisk(t *testing.T) {
|
||||
{name: "macOS, mémoire unifiée : tout le modèle compte",
|
||||
args: "--load-mode mmap+mlock", si: serveSysInfo{ModelBytes: 60 * gib, RAMMiB: 64 << 10, UnifiedMem: true}, refuse: true},
|
||||
{name: "moteur ancien : --no-mmap",
|
||||
args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
|
||||
args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
|
||||
{name: "moteur ancien : --mlock --no-mmap",
|
||||
args: "--mlock --no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
|
||||
{name: "LLAMA_ARG_NO_MMAP dans l'environnement",
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10,
|
||||
ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}, refuse: true},
|
||||
// Corrections de relecture du lot 2 : pas de refus sur une borne fausse.
|
||||
{name: "LLAMA_ARG_NO_MMAP sans effet sur un moteur à --load-mode : rien",
|
||||
@@ -138,7 +140,16 @@ func TestLoadModeRisk(t *testing.T) {
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
|
||||
ArgEnv: map[string]string{"LLAMA_ARG_RPC": "10.0.0.2:50052"}}, warn: true},
|
||||
{name: "limite du conteneur (RAM effective) : refus",
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, VRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true},
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true},
|
||||
// Lot 3 : la VRAM comptée est celle des cartes du moteur ; nvidia-smi
|
||||
// seul ne fait jamais refuser.
|
||||
{name: "VRAM NVIDIA seule, cartes du moteur non lues : avertissement, jamais de refus",
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
|
||||
{name: "VRAM NVIDIA seule, LOAD_GUARD=off : l'avis ordinaire",
|
||||
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
|
||||
{name: "Vulkan, NVIDIA 12 Gio + AMD 16 Gio listées par le moteur : rien (faux refus sur la seule VRAM NVIDIA)",
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 70 * gib, VRAMMiB: 12 << 10, EngineVRAMMiB: 28 << 10, RAMMiB: 60 << 10}},
|
||||
{name: "taille inconnue : rien",
|
||||
args: "--load-mode none", si: serveSysInfo{VRAMMiB: 16 << 10, RAMMiB: 16 << 10}},
|
||||
}
|
||||
@@ -154,6 +165,9 @@ func TestLoadModeRisk(t *testing.T) {
|
||||
if refuse != "" && !strings.Contains(refuse, "LOAD_GUARD=off") {
|
||||
t.Errorf("%s : refus sans clé d'échappement : %q", c.name, refuse)
|
||||
}
|
||||
if loadGuardOff(cfg) && strings.Contains(warn, "refus") {
|
||||
t.Errorf("%s : refus évoqué avec LOAD_GUARD=off : %q", c.name, warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +287,7 @@ func TestJoinArgsRoundTrip(t *testing.T) {
|
||||
// plus haut dans le journal.
|
||||
func TestModelLoadErrorLoadGuard(t *testing.T) {
|
||||
_, refuse := loadModeRisk(map[string]string{}, splitArgs("--load-mode none"),
|
||||
serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10})
|
||||
serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10})
|
||||
log := "llama_model_load: loading model\nmain: model loaded\nserver is listening\n[ERREUR] " + refuse + "\n"
|
||||
if got := modelLoadErrorFrom(log); !strings.Contains(got, "LOAD_GUARD=off") {
|
||||
t.Errorf("refus non signalé : %q", got)
|
||||
@@ -283,3 +297,93 @@ func TestModelLoadErrorLoadGuard(t *testing.T) {
|
||||
t.Errorf("refus ancien encore signalé : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un refus de LOAD_GUARD ne doit pas être relancé en boucle : l'unité systemd
|
||||
// écrite par « loki install » exclut son code de sortie, une unité d'avant ne
|
||||
// le fait pas.
|
||||
func TestUnitPreventsRefusalRestart(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
unit string
|
||||
want bool
|
||||
}{
|
||||
{"[Service]\nRestart=on-failure\nRestartSec=3\n", false},
|
||||
{"[Service]\nRestart=on-failure\nRestartPreventExitStatus=78\n", true},
|
||||
{"[Service]\n RestartPreventExitStatus = 1 78 SIGKILL\n", true},
|
||||
{"[Service]\nRestartPreventExitStatus=178\n", false},
|
||||
{"[Service]\n# RestartPreventExitStatus=78\n", false},
|
||||
} {
|
||||
if got := unitPreventsRefusalRestart(c.unit); got != c.want {
|
||||
t.Errorf("%q : %v, attendu %v", c.unit, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le refus sort avec son propre code (pas 1), y compris enveloppé ; une erreur
|
||||
// ordinaire garde 1. Le moteur d'essai garde toujours le vrai code.
|
||||
func TestLoadGuardRefusalExitStatus(t *testing.T) {
|
||||
err := loadGuardRefusal("refus ; "+loadGuardMarker, true)
|
||||
if got := exitStatusOf(err); got != loadGuardExitStatus {
|
||||
t.Errorf("essai : code %d, attendu %d", got, loadGuardExitStatus)
|
||||
}
|
||||
if !strings.Contains(err.Error(), loadGuardMarker) {
|
||||
t.Errorf("raison perdue : %q", err)
|
||||
}
|
||||
if got := exitStatusOf(fmt.Errorf("enveloppé : %w", err)); got != loadGuardExitStatus {
|
||||
t.Errorf("enveloppé : code %d", got)
|
||||
}
|
||||
if got := exitStatusOf(errors.New("modèle introuvable")); got != 1 {
|
||||
t.Errorf("erreur ordinaire : code %d, attendu 1", got)
|
||||
}
|
||||
// Hors superviseur (les tests ne tournent ni sous systemd ni sous launchd) :
|
||||
// le vrai code aussi.
|
||||
if restarts, _ := supervisorRestartsRefusal(); !restarts {
|
||||
if got := exitStatusOf(loadGuardRefusal("refus", false)); got != loadGuardExitStatus {
|
||||
t.Errorf("hors superviseur : code %d", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le refus envisagé sur la VRAM NVIDIA attend les cartes du moteur ; rien
|
||||
// n'est lu sans refus en vue.
|
||||
func TestLoadGuardNeedsDevices(t *testing.T) {
|
||||
none := splitArgs("--load-mode none")
|
||||
base := serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}
|
||||
if !loadGuardNeedsDevices(map[string]string{}, none, base) {
|
||||
t.Error("refus en vue sur nvidia-smi : cartes du moteur non demandées")
|
||||
}
|
||||
confirmed := base
|
||||
confirmed.EngineVRAMMiB = 16 << 10
|
||||
for name, c := range map[string]struct {
|
||||
cfg map[string]string
|
||||
args []string
|
||||
si serveSysInfo
|
||||
}{
|
||||
"déjà lues": {map[string]string{}, none, confirmed},
|
||||
"LOAD_GUARD=off": {map[string]string{"LOAD_GUARD": "off"}, none, base},
|
||||
"mmap": {map[string]string{}, splitArgs("--load-mode mmap"), base},
|
||||
"VRAM inconnue": {map[string]string{}, none, serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}},
|
||||
"loin du seuil": {map[string]string{}, none, serveSysInfo{ModelBytes: 30 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
|
||||
} {
|
||||
if loadGuardNeedsDevices(c.cfg, c.args, c.si) {
|
||||
t.Errorf("%s : cartes demandées sans refus en vue", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La VRAM des cartes du moteur : toutes additionnées, inconnue dès qu'une
|
||||
// carte annonce 0 Mio (déjà pleine) ; devices.json relu en JSON (float64).
|
||||
func TestEngineDevsVRAM(t *testing.T) {
|
||||
mixed := splitDevsFrom([]map[string]any{
|
||||
{"id": "Vulkan0", "total_mib": 12288},
|
||||
{"id": "Vulkan1", "total_mib": float64(16368)},
|
||||
})
|
||||
if got := engineDevsVRAM(mixed); got != 12288+16368 {
|
||||
t.Errorf("cartes mixtes : %d", got)
|
||||
}
|
||||
if got := engineDevsVRAM([]splitDev{{"CUDA0", 12288}, {"CUDA1", 0}}); got != 0 {
|
||||
t.Errorf("carte à 0 Mio : %d, attendu inconnu", got)
|
||||
}
|
||||
if got := engineDevsVRAM(nil); got != 0 {
|
||||
t.Errorf("liste vide : %d", got)
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
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).
|
||||
// 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) {
|
||||
// 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)
|
||||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||
switch mode {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
|
||||
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
||||
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",
|
||||
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"},
|
||||
@@ -493,3 +501,26 @@ func TestSynthAcceptanceWarned(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -98,17 +98,39 @@ func probeSplit(cfg map[string]string, bin string, si *serveSysInfo) {
|
||||
if v := os.Getenv("GGML_CUDA_ALLREDUCE"); v != "" {
|
||||
si.UserEnv["GGML_CUDA_ALLREDUCE"] = v
|
||||
}
|
||||
si.SplitDevs = listEngineDevices(bin)
|
||||
}
|
||||
|
||||
// listEngineDevices : les cartes que CE moteur voit (--list-devices), dans
|
||||
// l'environnement du processus — celui du vrai lancement quand cmdServe
|
||||
// appelle. nil si le moteur échoue : sortie tronquée (carte pleine, moteur
|
||||
// planté), on ne s'y fie pas.
|
||||
func listEngineDevices(bin string) []splitDev {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, bin, "--list-devices")).CombinedOutput()
|
||||
if err != nil {
|
||||
return // sortie tronquée (carte pleine, moteur planté) : on ne s'y fie pas
|
||||
return nil
|
||||
}
|
||||
for _, d := range parseListDevices(string(out)) {
|
||||
return splitDevsFrom(parseListDevices(string(out)))
|
||||
}
|
||||
|
||||
// splitDevsFrom : les cartes de parseListDevices (ou de devices.json, relu en
|
||||
// JSON : les nombres y sont des float64).
|
||||
func splitDevsFrom(list []map[string]any) []splitDev {
|
||||
var devs []splitDev
|
||||
for _, d := range list {
|
||||
id, _ := d["id"].(string)
|
||||
total, _ := d["total_mib"].(int)
|
||||
si.SplitDevs = append(si.SplitDevs, splitDev{ID: id, TotalMiB: int64(total)})
|
||||
var total int64
|
||||
switch v := d["total_mib"].(type) {
|
||||
case int:
|
||||
total = int64(v)
|
||||
case float64:
|
||||
total = int64(v)
|
||||
}
|
||||
devs = append(devs, splitDev{ID: id, TotalMiB: total})
|
||||
}
|
||||
return devs
|
||||
}
|
||||
|
||||
// splitPlan décide de SPLIT_MODE=tensor pour ce lancement. Fonction pure.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strataArgs(t *testing.T, cfg map[string]any) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for _, a := range cfg["args"].([]any) {
|
||||
out = append(out, a.(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func argAfter(args []string, flag string) string {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigApplyTuning(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"exe": "/x/engine-cuda12/strata",
|
||||
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
|
||||
"port": 8080.0,
|
||||
}
|
||||
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k", "ram")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
for flag, want := range map[string]string{"--prefill": "16384", "--short-read": "150", "--prompt-cache-root": "256", "--max-context": "65536", "--pack": "/p"} {
|
||||
if got := argAfter(a, flag); got != want {
|
||||
t.Errorf("%s = %q, attendu %q (args %v)", flag, got, want, a)
|
||||
}
|
||||
}
|
||||
if strings.Count(strings.Join(a, " "), "--prefill") != 1 {
|
||||
t.Errorf("--prefill dupliqué : %v", a)
|
||||
}
|
||||
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
|
||||
t.Errorf("aide 2e carte sans 2e carte : %v", a)
|
||||
}
|
||||
if out["port"] != 8081 || out["api_key"] != "k" || out["host"] != "127.0.0.1" {
|
||||
t.Errorf("port/clé/hôte : %v %v %v", out["port"], out["api_key"], out["host"])
|
||||
}
|
||||
// la config de l'installeur n'est pas modifiée
|
||||
if base["args"].([]any)[3] != "8192" {
|
||||
t.Error("la config de base a été modifiée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigHelperGPU(t *testing.T) {
|
||||
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}}
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--expert-cache-device1") != "auto" || !strataHasArg(a, "--remote-expert-opt") {
|
||||
t.Errorf("aide 2e carte absente : %v", a)
|
||||
}
|
||||
if strataHasArg(a, "--vram-reserve-mib") || strataHasArg(a, "700") {
|
||||
t.Errorf("réserve de VRAM gardée : %v", a)
|
||||
}
|
||||
if _, has := out["api_key"]; has {
|
||||
t.Error("api_key posée sans clé")
|
||||
}
|
||||
if _, has := out["gpu"]; has {
|
||||
t.Error("clé gpu gardée avec une carte d'aide (le serveur en ferait un --layer-split)")
|
||||
}
|
||||
if out["vision"].(map[string]any)["cuda_device"] != 0 {
|
||||
t.Errorf("encodeur pas sur la carte d'aide : %v", out["vision"])
|
||||
}
|
||||
if out["env"].(map[string]any)["STRATA_PLE_BATCH"] != "0" {
|
||||
t.Errorf("STRATA_PLE_BATCH absent : %v", out["env"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataCudaDevices(t *testing.T) {
|
||||
cases := []struct {
|
||||
main, helper, want string
|
||||
}{
|
||||
{"1", "0", "1,0"},
|
||||
{"0", "-1", "0"},
|
||||
{"0", "", "0"},
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": c.main, "STRATA_HELPER_GPU": c.helper})
|
||||
if got != c.want {
|
||||
t.Errorf("main=%q helper=%q : %q, attendu %q", c.main, c.helper, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataRecommend(t *testing.T) {
|
||||
// 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, AvailGB: 47, DiskFreeGB: 400,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||
if got := strataRecommend("swift", env); got != "IQ3_XXS" {
|
||||
t.Errorf("swift : %q, attendu IQ3_XXS", got)
|
||||
}
|
||||
// IQ3_S demande de garder 50 Go d'experts en RAM : pas sur 47 Go
|
||||
if got := strataRecommend("qwen", env); got != "IQ3_XXS" {
|
||||
t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got)
|
||||
}
|
||||
f := strataFitFor(strataQuants["IQ3_XXS"], env, 0)
|
||||
// trop juste pour tout garder en RAM : experts lus depuis le disque (le mode
|
||||
// hors RAM demande un experts.bin, qu'une installation neuve n'a pas)
|
||||
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
|
||||
env.RAMGB, env.AvailGB = 64, 64
|
||||
if got := strataRecommend("qwen", env); got != "IQ3_S" {
|
||||
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
|
||||
}
|
||||
// 16 Go de RAM et une seule carte de 8 Go : rien ne tient
|
||||
small := strataEnv{Supported: true, Main: 0, Helper: -1, RAMGB: 16, DiskFreeGB: 400,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}}}
|
||||
if got := strataRecommend("swift", small); got != "" {
|
||||
t.Errorf("petite machine : %q, attendu aucune recommandation", got)
|
||||
}
|
||||
// disque trop petit
|
||||
env.DiskFreeGB = 50
|
||||
if f := strataFitFor(strataQuants["Q2_0"], env, 0); f.OK {
|
||||
t.Error("Q2_0 accepté avec 50 Go de disque")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataPresetName(t *testing.T) {
|
||||
if got := strataPresetName("swift", "IQ3_XXS"); got != "STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
if got := strataPresetName("qwen", "Q2_0"); got != "STRATA FLASH NEXT CLASSIQUE Q2_0" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataFitCountsExistingData(t *testing.T) {
|
||||
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}}}
|
||||
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 120); !f.OK {
|
||||
t.Errorf("données déjà présentes, refusé quand même : %s", f.Why)
|
||||
}
|
||||
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 0); f.OK {
|
||||
t.Error("32 Go libres sans données : accepté")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigPresetOverrides(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}}
|
||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "", "ram")
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
|
||||
t.Errorf("%v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDetailsFor(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
base := filepath.Join(home, "base.json")
|
||||
if err := os.WriteFile(base, []byte(`{"args":["--pack","/p","--kv","int8","--spec","4","--max-context","131072"],"vision":{"exe":"v"}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_MMAP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\n"
|
||||
if err := os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3070", VRAMGB: 8}, {Index: 1, Name: "NVIDIA GeForce RTX 5060 Ti", VRAMGB: 16}}}
|
||||
d := strataDetailsFor("X", env)
|
||||
if d == nil {
|
||||
t.Fatal("pas de détails")
|
||||
}
|
||||
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
|
||||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
|
||||
d.ShortRead != "48" || d.CacheRoot != "256" || d.Prefill != "16384" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataHaveGBPerChoice(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
write := func(rel string, n int) {
|
||||
p := filepath.Join(strataDataDir(), rel)
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
if err := os.WriteFile(p, make([]byte, n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("models/swift-IQ3_XXS/a.gguf", 3000)
|
||||
write("packs/swift-iq3_xxs/experts.bin", 2000)
|
||||
write("mtp/rt/x", 500)
|
||||
write("models/IQ2_XS/b.gguf", 7000) // un autre choix : ne compte pas pour swift IQ3_XXS
|
||||
if got := strataHaveGB("swift", "IQ3_XXS") * 1e9; got != 5500 {
|
||||
t.Errorf("swift IQ3_XXS : %v octets, attendu 5500", got)
|
||||
}
|
||||
if got := strataHaveGB("swift", "IQ2_XS") * 1e9; got != 500 {
|
||||
t.Errorf("swift IQ2_XS : %v octets, attendu 500 (seulement la tête MTP commune)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettings(t *testing.T) {
|
||||
in := "# NAME=x\nENGINE=strata\nCTX=131072\nSTRATA_KV=fp16\nSTRATA_SPEC=3\nSTRATA_HELPER_GPU=0\n"
|
||||
out, err := strataApplySettings(in, strataSettingsReq{Ctx: 65536, KV: "int8", Spec: 4, Vision: false, Helper: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := parseEnv(out)
|
||||
if cfg["CTX"] != "65536" || cfg["STRATA_KV"] != "int8" || cfg["STRATA_SPEC"] != "4" || cfg["STRATA_VISION"] != "0" ||
|
||||
cfg["STRATA_HELPER"] != "0" || cfg["STRATA_HELPER_GPU"] != "0" || cfg["ENGINE"] != "strata" {
|
||||
t.Errorf("%q", out)
|
||||
}
|
||||
if strings.Count(out, "CTX=") != 1 {
|
||||
t.Errorf("CTX dupliqué : %q", out)
|
||||
}
|
||||
if strataHelperOn(cfg) || strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}) != "1" {
|
||||
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"}}
|
||||
b, _ := strataBuildConfig(base, cfg, "", "ram")
|
||||
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)
|
||||
}
|
||||
for _, bad := range []strataSettingsReq{{Ctx: 1000, KV: "fp16", Spec: 3}, {Ctx: 32768, KV: "q2", Spec: 3}, {Ctx: 32768, KV: "fp16", Spec: 9}} {
|
||||
if _, err := strataApplySettings(in, bad); err == nil {
|
||||
t.Errorf("accepté : %+v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineOf(t *testing.T) {
|
||||
cases := map[string]string{"": engineLlamacpp, "llamacpp": engineLlamacpp, "strata": engineStrata, " Strata ": engineStrata, "moe": engineStrata, "autre": engineLlamacpp}
|
||||
for in, want := range cases {
|
||||
if got := engineOf(map[string]string{"ENGINE": in}); got != want {
|
||||
t.Errorf("ENGINE=%q : %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelLoadErrorStrata(t *testing.T) {
|
||||
ok := strataServeMarker + " (paquet AJEAN MoE 1.0)\nloading the model (the first start takes a minute or two) ...\nready: http://127.0.0.1:8080/v1 (OpenAI: /v1/chat/completions)\n"
|
||||
if got := modelLoadErrorFrom(ok); got != "" {
|
||||
t.Errorf("Strata prêt signalé en échec : %q", got)
|
||||
}
|
||||
crash := strataServeMarker + "\nTraceback (most recent call last):\n File \"server.py\", line 1\nRuntimeError: boom\n"
|
||||
if got := modelLoadErrorFrom(crash); !strings.Contains(got, "Strata") {
|
||||
t.Errorf("trace Python non signalée : %q", got)
|
||||
}
|
||||
// une trace d'un lancement PRÉCÉDENT ne compte pas
|
||||
if got := modelLoadErrorFrom(crash + strataServeMarker + "\nloading the model ...\n"); 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)
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,13 @@ type tuneProc struct {
|
||||
|
||||
// tuneOwner : le contenu du verrou.
|
||||
type tuneOwner struct {
|
||||
Owner tuneProc `json:"owner"`
|
||||
Via string `json:"via"` // « web » ou « cli »
|
||||
Phase string `json:"phase"` // « essais » ou « application »
|
||||
Since int64 `json:"since"`
|
||||
Owner tuneProc `json:"owner"`
|
||||
Via string `json:"via"` // « web » ou « cli »
|
||||
Phase string `json:"phase"` // « essais » ou « application »
|
||||
Since int64 `json:"since"`
|
||||
// Nonce : distingue deux verrous pris par le même processus dans la même
|
||||
// seconde (tuneHeldKey). Absent des verrous d'une version d'avant : 0.
|
||||
Nonce int64 `json:"nonce,omitempty"`
|
||||
Trial *tuneProc `json:"trial,omitempty"`
|
||||
// MainWasActive : le vrai moteur tournait avant l'optimisation. Un verrou
|
||||
// périmé trouvé au démarrage du processus web le relance.
|
||||
@@ -143,7 +146,16 @@ func tuneActive() (tuneOwner, bool) {
|
||||
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
||||
var tuneHeld sync.Map
|
||||
|
||||
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d", o.Via, o.Since) }
|
||||
// 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) }
|
||||
|
||||
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
|
||||
// porte NOTRE PID sans être l'un des nôtres vient d'un processus mort dont le
|
||||
@@ -190,8 +202,15 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
return nil, err
|
||||
}
|
||||
l := &tuneLock{path: path, owner: tuneOwner{Owner: selfProc(), Via: via, Phase: "essais",
|
||||
Since: time.Now().Unix(), MainWasActive: mainActive}}
|
||||
Since: time.Now().Unix(), Nonce: time.Now().UnixNano(), MainWasActive: mainActive}}
|
||||
// Inscrit comme nôtre AVANT que le fichier n'existe : la reprise du
|
||||
// processus web (tuneRecoverWatch, toutes les 30 s) tient pour mort un
|
||||
// verrou qui porte notre PID sans être inscrit, et l'écarterait entre la
|
||||
// création et l'inscription.
|
||||
key := tuneHeldKey(l.owner)
|
||||
tuneHeld.Store(key, true)
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
tuneFileMu.Lock()
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
b, _ := json.Marshal(l.owner)
|
||||
@@ -199,27 +218,40 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
cerr := f.Close()
|
||||
if werr != nil || cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
tuneHeld.Delete(key)
|
||||
tuneFileMu.Unlock()
|
||||
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
||||
}
|
||||
l.held = true
|
||||
tuneHeld.Store(tuneHeldKey(l.owner), true)
|
||||
tuneFileMu.Unlock()
|
||||
return l, nil
|
||||
}
|
||||
tuneFileMu.Unlock()
|
||||
if !os.IsExist(err) {
|
||||
tuneHeld.Delete(key)
|
||||
return nil, err
|
||||
}
|
||||
if o, busy := tuneActive(); busy {
|
||||
tuneHeld.Delete(key)
|
||||
return nil, fmt.Errorf("une optimisation tourne déjà (%s, PID %d, depuis %s)", o.Via, o.Owner.PID,
|
||||
time.Unix(o.Since, 0).Format("15:04"))
|
||||
}
|
||||
tuneReapStale()
|
||||
}
|
||||
tuneHeld.Delete(key)
|
||||
return nil, errors.New("verrou de l'optimiseur indisponible : réessaie")
|
||||
}
|
||||
|
||||
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
||||
// lecteur ne voit jamais un JSON à moitié écrit.
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -267,14 +299,47 @@ func (l *tuneLock) setBackup(backup, id, name string) {
|
||||
}
|
||||
|
||||
// release rend le verrou — seulement s'il est encore le nôtre.
|
||||
//
|
||||
// La reprise du processus web (tuneRecoverWatch) tourne toutes les 30 s,
|
||||
// pendant que ce même processus prend et rend ses verrous. Elle tient pour
|
||||
// mort un verrou à notre PID qui n'est plus inscrit (tuneHeld) : si le fichier
|
||||
// survivait à l'inscription — sous Windows, un fichier qu'un autre lit à cet
|
||||
// instant ne se supprime pas —, elle l'écarterait, et en phase « application »
|
||||
// défairait le preset tout juste vérifié (tuneUndoApply), ou relancerait le
|
||||
// moteur. Le verrou est donc d'abord réécrit NEUTRE (rien à défaire, rien à
|
||||
// relancer, aucun essai), puis retiré, et l'inscription ne part qu'après.
|
||||
func (l *tuneLock) release() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
if l.held {
|
||||
// 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.
|
||||
ours := false
|
||||
for i := 0; i < 5; i++ {
|
||||
if o, ok := readTuneOwner(l.path); ok {
|
||||
ours = sameTuneLock(o, l.owner)
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if ours {
|
||||
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
||||
_ = l.writeLocked()
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
l.held = false
|
||||
tuneHeld.Delete(tuneHeldKey(l.owner))
|
||||
return
|
||||
}
|
||||
if o, ok := readTuneOwner(l.path); ok && o.Owner.PID == l.owner.Owner.PID && o.Owner.Start == l.owner.Owner.Start {
|
||||
if o, ok := readTuneOwner(l.path); ok && sameTuneLock(o, l.owner) {
|
||||
_ = os.Remove(l.path)
|
||||
}
|
||||
}
|
||||
@@ -285,6 +350,24 @@ func (l *tuneLock) release() {
|
||||
// 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.
|
||||
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()
|
||||
o, ok := readTuneOwner(path)
|
||||
if !ok {
|
||||
@@ -295,22 +378,35 @@ func tuneReapStale() (mainWasActive bool) {
|
||||
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
if tuneOwnerAlive(o) {
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
||||
if os.Rename(path, stale) != nil {
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
defer os.Remove(stale)
|
||||
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)
|
||||
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
|
||||
// neuf, vivant, pris à sa place : c'est alors CELUI-LÀ qu'on vient de
|
||||
// déplacer. La reprise du processus web tournant toutes les 30 s, pendant
|
||||
// que ce même processus prend et rend ses verrous, ce n'est plus un cas
|
||||
// d'école. On relit donc ce qu'on a déplacé ; un autre verrou est remis en
|
||||
// place (un lien : jamais par-dessus un verrou posé entre-temps).
|
||||
if got, ok := readTuneOwner(stale); !ok || !sameTuneLock(got, o) || tuneOwnerAlive(got) {
|
||||
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)
|
||||
}
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
tuneUndoApply(o)
|
||||
return o.MainWasActive
|
||||
return o, true
|
||||
}
|
||||
|
||||
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
||||
// instant de la prise), quelle que soit sa phase ?
|
||||
func sameTuneLock(a, b tuneOwner) bool {
|
||||
return a.Owner.PID == b.Owner.PID && a.Owner.Start == b.Owner.Start && tuneHeldKey(a) == tuneHeldKey(b)
|
||||
}
|
||||
|
||||
// tuneUndoApply : une application interrompue avant sa vérification (Loki tué
|
||||
@@ -332,20 +428,50 @@ func tuneUndoApply(o tuneOwner) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue avant sa vérification : ancienne version du preset rétablie\n")
|
||||
}
|
||||
|
||||
// tuneRecoverAtBoot : au démarrage du processus web, une optimisation
|
||||
// interrompue (Loki tué en plein essai) a laissé le vrai moteur arrêté et peut-
|
||||
// tuneRecoverStale : une optimisation interrompue (Loki redémarré en plein
|
||||
// essai, « loki tune » tué par kill -9) a laissé le vrai moteur arrêté et peut-
|
||||
// être un essai en VRAM. On arrête l'essai, puis on relance le moteur s'il
|
||||
// tournait avant — ce que l'optimisation aurait fait en finissant.
|
||||
func tuneRecoverAtBoot() {
|
||||
// tournait avant — ce que l'optimisation aurait fait en finissant. Sans
|
||||
// verrou, un simple stat : rien d'autre n'est lu.
|
||||
func tuneRecoverStale() {
|
||||
if _, err := os.Stat(tuneLockPath()); err != nil {
|
||||
return
|
||||
}
|
||||
if !tuneReapStale() || externalActive() || serviceIsActive() {
|
||||
if tuneReapStale() {
|
||||
tuneRecoverStart()
|
||||
}
|
||||
}
|
||||
|
||||
// tuneRecoverStart relance le moteur après une optimisation interrompue, sauf
|
||||
// preset externe, moteur déjà reparti ou configuration incomplète. Variable
|
||||
// pour les tests.
|
||||
var tuneRecoverStart = func() {
|
||||
if externalActive() || serviceIsActive() {
|
||||
return
|
||||
}
|
||||
if err := preflightEngine(); err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Println(dim("[loki tune] optimisation interrompue : relance du moteur"))
|
||||
_ = serviceActionOS("start")
|
||||
// serviceAction, pas serviceActionOS : la reprise tourne désormais toutes
|
||||
// les 30 s, et une optimisation neuve a pu prendre le verrou entre le
|
||||
// ménage et ce démarrage — tuneGuard le voit, et le moteur reste arrêté
|
||||
// pour elle.
|
||||
_ = serviceAction("start")
|
||||
}
|
||||
|
||||
// tuneRecoverEvery : la période de la reprise dans le processus web.
|
||||
const tuneRecoverEvery = 30 * time.Second
|
||||
|
||||
// tuneRecoverWatch : au démarrage du processus web, puis à chaque tic. Le
|
||||
// démarrage seul ne suffisait pas : un « loki tune » en ligne de commande tué
|
||||
// sans ses defers (kill -9, terminal perdu) laissait le moteur arrêté jusqu'au
|
||||
// prochain redémarrage de l'interface. Le tic ne coûte qu'un stat tant
|
||||
// qu'aucun verrou n'existe ; pendant une optimisation vivante, une lecture du
|
||||
// verrou et de l'identité de son propriétaire.
|
||||
func tuneRecoverWatch(tick <-chan time.Time) {
|
||||
tuneRecoverStale()
|
||||
for range tick {
|
||||
tuneRecoverStale()
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,9 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
|
||||
if externalActive() {
|
||||
return errors.New("optimisation indisponible : le preset actif est une API externe")
|
||||
}
|
||||
if strataActive() {
|
||||
return errors.New("optimisation indisponible : le preset actif tourne sur Strata, réglé par sa propre fenêtre")
|
||||
}
|
||||
if err := tuneGuard(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,6 +200,17 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
|
||||
bgJobsRunning())
|
||||
}
|
||||
}
|
||||
// En ligne de commande : le moteur qui répond sur le port de CE LOKI_HOME
|
||||
// est-il bien le sien ? (Le processus web, lui, fait foi pour son moteur.)
|
||||
if !inWeb {
|
||||
cfg := ReadConfig()
|
||||
ours, _ := resolveServeModelPath(strings.TrimSpace(cfg["MODEL"]))
|
||||
auth := tuneAuth()
|
||||
if err := tuneForeignEngine(ctx, fmt.Sprintf("http://localhost:%d", LLMPort()), serviceIsActive(),
|
||||
func(r *http.Request) { auth(r.Header.Set) }, ours); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if serviceIsActive() {
|
||||
e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient}
|
||||
if err := e.idle(ctx); err != nil {
|
||||
@@ -206,6 +220,58 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tuneForeignEngine : « loki tune » et le processus web avec des LOKI_HOME
|
||||
// différents (sudo qui retire la variable, LOKI_HOME exporté dans un seul
|
||||
// shell) ne se voient pas : le verrou de l'optimisation tombe dans un dossier
|
||||
// que l'interface ne lit pas, et l'arrêt du « vrai » moteur vise celui d'une
|
||||
// autre configuration. L'essai chargeait alors à côté d'un moteur bien vivant
|
||||
// — VRAM saturée, mesures fausses — et l'interface pouvait relancer le sien
|
||||
// en pleine mesure. On regarde donc ce qui répond sur le port de CE
|
||||
// LOKI_HOME :
|
||||
//
|
||||
// - un serveur répond alors que ce LOKI_HOME dit son moteur arrêté : un autre
|
||||
// Loki, ou un llama-server orphelin ;
|
||||
// - le moteur refuse la clé d'API de ce LOKI_HOME (401 sur /props), ou sert
|
||||
// un autre fichier que son MODEL : le moteur d'une autre configuration.
|
||||
//
|
||||
// Tout ce qui ne conclut pas (pas de réponse, /props absent d'un moteur
|
||||
// ancien, chemin illisible d'ici) laisse passer : seul un désaccord constaté
|
||||
// refuse.
|
||||
func tuneForeignEngine(ctx context.Context, base string, active bool, auth func(*http.Request), ourModel string) error {
|
||||
p := tplProber{base: base, auth: auth, client: &http.Client{Timeout: 2 * time.Second}}
|
||||
// /health d'un llama-server : 200, 503 en chargement, 401 derrière une clé
|
||||
// sur certaines versions. Rien n'écoute, ou un autre service (404…) : aucun
|
||||
// moteur à craindre.
|
||||
var he *tplHTTPError
|
||||
if _, err := p.do(ctx, http.MethodGet, "/health", nil); err != nil &&
|
||||
(!errors.As(err, &he) || (he.status != http.StatusServiceUnavailable && he.status != http.StatusUnauthorized)) {
|
||||
return nil
|
||||
}
|
||||
home := LokiHome()
|
||||
foreign := func(why string) error {
|
||||
return fmt.Errorf("%s, alors que LOKI_HOME=%s — un autre Loki tourne sans doute avec un autre LOKI_HOME "+
|
||||
"(sudo retire la variable) : relance avec le sien (LOKI_HOME=… loki tune) ou utilise le bouton "+
|
||||
"« Optimiser… » de l'interface ; un llama-server orphelin, lui, s'arrête à la main", why, home)
|
||||
}
|
||||
if !active {
|
||||
return foreign("un moteur répond sur " + base + " sans que ce dossier le suive")
|
||||
}
|
||||
props, err := p.props(ctx)
|
||||
switch {
|
||||
case errors.As(err, &he) && he.status == http.StatusUnauthorized:
|
||||
return foreign("le moteur de " + base + " refuse la clé d'API de ce dossier")
|
||||
case err != nil || props.ModelPath == "" || ourModel == "":
|
||||
return nil
|
||||
}
|
||||
a, errA := os.Stat(props.ModelPath)
|
||||
b, errB := os.Stat(ourModel)
|
||||
if errA == nil && errB == nil && !os.SameFile(a, b) {
|
||||
return foreign(fmt.Sprintf("le moteur de %s sert %s, pas le MODEL de ce dossier (%s ; config.env modifiée "+
|
||||
"sans redémarrage ? « loki restart »)", base, filepath.Base(props.ModelPath), filepath.Base(ourModel)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tunePhysCores : cœurs physiques (Linux ; ailleurs inconnu, l'axe threads est
|
||||
// sauté). Un conteneur à l'étroit (cpuset, quota) donne sa propre limite.
|
||||
func tunePhysCores(probe *tuneProbe) int {
|
||||
@@ -486,7 +552,7 @@ func runTune(ctx context.Context, opts tuneOpts, tr *tuneTracker) (out *tuneResu
|
||||
|
||||
res := &tuneResult{PresetID: preset.ID, PresetName: preset.Name, BaseFP: configFingerprint(cfg),
|
||||
PresetFP: presetFingerprint([]byte(content)), OptIn: opts.OptIn, At: time.Now().Unix(),
|
||||
Weights: tuneWeightsFrom(perfLog.snapshot())}
|
||||
Weights: tuneWeightsFrom(perfSnapshot())}
|
||||
|
||||
// Référence : la ligne composée telle quelle, la sonde de la machine.
|
||||
tr.setPhase("référence : composition de la ligne de commande")
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -832,3 +835,168 @@ func TestTuneUndoInterruptedApply(t *testing.T) {
|
||||
t.Error("configuration modifiée hors phase d'application")
|
||||
}
|
||||
}
|
||||
|
||||
// Reprise dans le processus web : un « loki tune » tué sans ses defers ne
|
||||
// laisse plus le moteur arrêté jusqu'au prochain démarrage de l'interface. Le
|
||||
// tic suivant écarte le verrou périmé et relance le moteur — une fois, et
|
||||
// seulement s'il tournait avant. Sans verrou, ni lecture ni relance.
|
||||
func TestTuneRecoverWatch(t *testing.T) {
|
||||
testHome(t)
|
||||
alive := true
|
||||
prevAlive, prevStart := tuneProcAlive, tuneRecoverStart
|
||||
tuneProcAlive = func(p tuneProc) bool { return alive && p.PID == os.Getpid() }
|
||||
starts := 0
|
||||
tuneRecoverStart = func() { starts++ }
|
||||
t.Cleanup(func() { tuneProcAlive, tuneRecoverStart = prevAlive, prevStart })
|
||||
|
||||
tick := make(chan time.Time)
|
||||
done := make(chan struct{})
|
||||
go func() { tuneRecoverWatch(tick); close(done) }()
|
||||
// Un envoi est reçu quand le tic précédent a fini : deux de suite, et le
|
||||
// premier est traité en entier.
|
||||
step := func() { tick <- time.Now() }
|
||||
step()
|
||||
step()
|
||||
if starts != 0 {
|
||||
t.Fatal("relance sans verrou")
|
||||
}
|
||||
l, err := tuneLockAcquire("cli", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
step()
|
||||
step()
|
||||
if starts != 0 {
|
||||
t.Fatal("relance pendant une optimisation vivante")
|
||||
}
|
||||
if _, err := os.Stat(tuneLockPath()); err != nil {
|
||||
t.Fatal("verrou vivant retiré")
|
||||
}
|
||||
alive = false // kill -9 : le verrou reste, son propriétaire est mort
|
||||
step()
|
||||
step()
|
||||
if starts != 1 {
|
||||
t.Fatalf("relances : %d, attendu 1", starts)
|
||||
}
|
||||
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
|
||||
t.Error("verrou périmé non retiré")
|
||||
}
|
||||
l.release()
|
||||
|
||||
// Le moteur ne tournait pas avant : verrou retiré, pas de relance.
|
||||
alive = true
|
||||
if _, err := tuneLockAcquire("cli", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alive = false
|
||||
step()
|
||||
step()
|
||||
close(tick)
|
||||
<-done
|
||||
if starts != 1 {
|
||||
t.Errorf("relance d'un moteur qui ne tournait pas : %d", starts)
|
||||
}
|
||||
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
|
||||
t.Error("verrou périmé non retiré")
|
||||
}
|
||||
}
|
||||
|
||||
// « loki tune » d'un autre LOKI_HOME que le processus web (sudo) : le moteur
|
||||
// qui répond sur le port de ce dossier n'est pas le sien — refus en clair.
|
||||
// Tout ce qui ne conclut pas laisse passer.
|
||||
func TestTuneForeignEngine(t *testing.T) {
|
||||
home := testHome(t)
|
||||
ours := filepath.Join(home, "ours.gguf")
|
||||
other := filepath.Join(home, "other.gguf")
|
||||
for _, p := range []string{ours, other} {
|
||||
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engine := func(health, props int, model string) *httptest.Server {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(health)
|
||||
case "/props":
|
||||
w.WriteHeader(props)
|
||||
if props == http.StatusOK {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"model_path": model})
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
closed := httptest.NewServer(http.NotFoundHandler())
|
||||
closed.Close()
|
||||
ctx := context.Background()
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
base string
|
||||
active bool
|
||||
refused bool
|
||||
}{
|
||||
{"rien n'écoute", closed.URL, false, false},
|
||||
{"autre service (404) sur le port, moteur arrêté", engine(404, 404, "").URL, false, false},
|
||||
{"moteur qui répond, ce dossier le dit arrêté", engine(200, 200, ours).URL, false, true},
|
||||
{"moteur en chargement (503), ce dossier le dit arrêté", engine(503, 503, "").URL, false, true},
|
||||
{"notre moteur", engine(200, 200, ours).URL, true, false},
|
||||
{"un autre MODEL", engine(200, 200, other).URL, true, true},
|
||||
{"clé d'API refusée", engine(200, 401, "").URL, true, true},
|
||||
{"/props absent (moteur ancien)", engine(200, 404, "").URL, true, false},
|
||||
{"chemin illisible d'ici", engine(200, 200, filepath.Join(home, "absent.gguf")).URL, true, false},
|
||||
} {
|
||||
err := tuneForeignEngine(ctx, c.base, c.active, nil, ours)
|
||||
if (err != nil) != c.refused {
|
||||
t.Errorf("%s : %v", c.name, err)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "LOKI_HOME="+home) {
|
||||
t.Errorf("%s : message sans LOKI_HOME : %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La reprise du processus web tourne toutes les 30 s, pendant que ce même
|
||||
// processus prend et rend ses propres verrous (bouton « Optimiser… »,
|
||||
// application d'un résultat). Elle ne doit jamais tenir l'un d'eux pour
|
||||
// périmé : entre la création du fichier et l'inscription, ou entre
|
||||
// l'inscription retirée et le fichier retiré, elle aurait défait un preset
|
||||
// tout juste vérifié (phase « application ») et relancé le moteur.
|
||||
func TestTuneRecoverNeverReapsOwnLock(t *testing.T) {
|
||||
testHome(t)
|
||||
prevAlive, prevStart := tuneProcAlive, tuneRecoverStart
|
||||
tuneProcAlive = func(p tuneProc) bool { return p.PID == os.Getpid() }
|
||||
var starts atomic.Int32
|
||||
tuneRecoverStart = func() { starts.Add(1) }
|
||||
t.Cleanup(func() { tuneProcAlive, tuneRecoverStart = prevAlive, prevStart })
|
||||
|
||||
stop := make(chan struct{})
|
||||
reaped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(reaped)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
tuneRecoverStale()
|
||||
}
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 60; i++ {
|
||||
l, err := tuneLockAcquire("apply", true)
|
||||
if err != nil {
|
||||
t.Fatalf("tour %d : %v", i, err)
|
||||
}
|
||||
l.setPhase("application")
|
||||
l.release()
|
||||
}
|
||||
close(stop)
|
||||
<-reaped
|
||||
if n := starts.Load(); n != 0 {
|
||||
t.Errorf("verrou vivant de ce processus écarté %d fois", n)
|
||||
}
|
||||
}
|
||||
@@ -76,19 +76,17 @@ func cmdChat(args []string) error {
|
||||
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
|
||||
}
|
||||
sent, tools := prepareTurn(msgs, caps)
|
||||
hist := msgs
|
||||
extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
|
||||
case ev.NewHistory != nil:
|
||||
// Compaction survenue pendant le tour : elle REMPLACE l'historique (elle
|
||||
// contient déjà le tour en cours), préfixe système injecté retiré. Sans
|
||||
// ça le terminal repartirait du fil complet au tour suivant.
|
||||
base := ev.NewHistory
|
||||
for len(base) > 0 && base[0].Role == "system" {
|
||||
base = base[1:]
|
||||
}
|
||||
msgs = append([]Message(nil), base...)
|
||||
// contient déjà le tour en cours), préfixe système injecté retiré —
|
||||
// le /sys de l'utilisateur, lui, reste (historyFromView). Sans ça le
|
||||
// terminal repartirait du fil complet au tour suivant.
|
||||
msgs = historyFromView(ev.NewHistory, hist)
|
||||
fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]"))
|
||||
case ev.Stats != nil:
|
||||
stats = ev.Stats
|
||||
@@ -117,7 +115,7 @@ func cmdChat(args []string) error {
|
||||
case "edit":
|
||||
icon = "✏️"
|
||||
verb = "édition"
|
||||
case "web_search", "web_open", "web_read", "web_grep":
|
||||
case "web_search", "web_images", "web_open", "web_read", "web_grep":
|
||||
icon = "🌐"
|
||||
verb = "web"
|
||||
case "web_screenshot":
|
||||
|
||||
@@ -242,6 +242,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
|
||||
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
|
||||
}
|
||||
|
||||
// compactRetryDue : une compaction en cours de tour peut-elle être tentée ?
|
||||
// Après un essai qui n'a rien réduit (résumé raté, réduction sous 20 %),
|
||||
// chaque étape suivante relançait un résumé complet voué au même échec : plus
|
||||
// d'une heure de tentatives à vide, une par étape, sur une longue tâche (AJEAN
|
||||
// 68659e73). compactRefusedSkip ne couvre que la discussion principale avec
|
||||
// COMPACT_CONTINUATION ; ce garde-fou vaut pour tout tour, tâches et
|
||||
// sous-agents compris. Jamais à 90 % de la fenêtre ou plus : là, essayer
|
||||
// encore vaut mieux que déborder.
|
||||
func compactRetryDue(retryAt, used int, msgs []Message) bool {
|
||||
if retryAt <= 0 {
|
||||
return true
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
return est >= retryAt || est*10 >= ctxWindow()*9
|
||||
}
|
||||
|
||||
// compactNextRetry : seuil du prochain essai après une compaction en cours de
|
||||
// tour. 0 (aucun) si elle a réduit ; sinon le contexte d'aujourd'hui plus 5 %
|
||||
// de la fenêtre.
|
||||
func compactNextRetry(changed bool, used int, msgs []Message) int {
|
||||
if changed {
|
||||
return 0
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
next := est + ctxWindow()/20
|
||||
fmt.Fprintf(os.Stderr, "[compact] rien réduit en cours de tour : prochain essai à %d jetons (contexte à %d)\n", next, est)
|
||||
return next
|
||||
}
|
||||
|
||||
func compactUsedOrEstimate(used int, msgs []Message) int {
|
||||
if used > 0 {
|
||||
return used
|
||||
}
|
||||
return estimateTokens(msgs)
|
||||
}
|
||||
|
||||
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
|
||||
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
|
||||
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
|
||||
@@ -437,18 +473,24 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
||||
var index []recallEntry
|
||||
if caps.Agent && compactEnabled() {
|
||||
names := torsoToolNames(torso)
|
||||
var blocks []recallArchiveInput
|
||||
var at []int // position dans le torse de chaque bloc
|
||||
for i, m := range torso {
|
||||
if !recallEligible(m) {
|
||||
continue
|
||||
}
|
||||
content := msgText(m)
|
||||
label := recallLabel(m.Role, names[m.ToolCallID], content)
|
||||
id, aerr := archiveRecallBlock(label, m.Role, content)
|
||||
if aerr != nil {
|
||||
continue
|
||||
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
|
||||
at = append(at, i)
|
||||
}
|
||||
// Une seule transaction pour tous les blocs (AJEAN #130).
|
||||
if entries, aerr := archiveRecallBlocks(blocks); aerr == nil {
|
||||
for k, e := range entries {
|
||||
archived[at[k]] = &recallEntry{id: e.id, label: e.label}
|
||||
index = append(index, e)
|
||||
}
|
||||
archived[i] = &recallEntry{id: id, label: label}
|
||||
index = append(index, recallEntry{id: id, label: label})
|
||||
} else if len(blocks) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +570,14 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
|
||||
} else {
|
||||
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
}
|
||||
// La cause d'un résumé raté n'était écrite nulle part : on ne voyait que des
|
||||
// compactions « changé=false » à répétition, sans savoir pourquoi (AJEAN
|
||||
// 68659e73).
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé raté : %v\n", err)
|
||||
} else if summaryLooksEmpty(summary) {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé vide ou dégénéré (%d car.)\n", len([]rune(strings.TrimSpace(summary))))
|
||||
}
|
||||
var mid []Message
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Après une compaction en cours de tour qui n'a rien réduit, l'étape suivante
|
||||
// ne relance pas un résumé tant que le contexte n'a pas grossi de 5 % de la
|
||||
// fenêtre — sauf à 90 % de la fenêtre ou plus.
|
||||
func TestCompactRetryBackoff(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkConfig, "CTX", "100000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !compactRetryDue(0, 70000, nil) {
|
||||
t.Fatal("sans échec précédent, l'essai doit être permis")
|
||||
}
|
||||
next := compactNextRetry(false, 70000, nil)
|
||||
if next != 75000 {
|
||||
t.Fatalf("prochain essai à %d, attendu 75000", next)
|
||||
}
|
||||
if compactRetryDue(next, 72000, nil) {
|
||||
t.Fatal("relancé alors que le contexte n'a presque pas grossi")
|
||||
}
|
||||
if !compactRetryDue(next, 75000, nil) {
|
||||
t.Fatal("pas relancé alors que le contexte a grossi de 5 % de la fenêtre")
|
||||
}
|
||||
if !compactRetryDue(200000, 90000, nil) {
|
||||
t.Fatal("à 90 % de la fenêtre, l'essai doit toujours être permis")
|
||||
}
|
||||
if compactNextRetry(true, 70000, nil) != 0 {
|
||||
t.Fatal("une compaction réussie doit lever le garde-fou")
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,8 @@ type Conversation struct {
|
||||
// errBenchBusy pour un bench, errTuneBusy pour l'optimiseur, qui prend le
|
||||
// même verrou (backend_tune_job.go).
|
||||
leaseWhy error
|
||||
// extLease : le verrou est tenu par la façade Rust (web_internal_lease.go).
|
||||
extLease *extLeaseState
|
||||
|
||||
// File d'attente des messages envoyés PENDANT une génération (AJEAN
|
||||
// 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière
|
||||
@@ -200,6 +202,7 @@ func LoadConversation() {
|
||||
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
|
||||
conv.Generating = false
|
||||
conv.cancel = nil
|
||||
conv.extLeaseClearLocked()
|
||||
}
|
||||
|
||||
// loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil
|
||||
@@ -235,6 +238,7 @@ func (c *Conversation) loadFrom(id string, b []byte) {
|
||||
if !c.benching {
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
c.extLeaseClearLocked() // le tour de la façade est rompu, comme un tour Go
|
||||
}
|
||||
c.epoch++
|
||||
c.cond.Broadcast()
|
||||
@@ -354,6 +358,17 @@ func compactLog(log []LogEvent) []LogEvent {
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
flush()
|
||||
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)
|
||||
}
|
||||
continue
|
||||
@@ -438,6 +453,47 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
return compacted, true
|
||||
}
|
||||
|
||||
// historyFromView rend l'historique à ranger après une compaction en cours de
|
||||
// tour : la vue publiée (NewHistory), sans les messages système que la vue
|
||||
// d'envoi avait posés en tête — préambule et prompt du preset, consignes du
|
||||
// dépôt, contexte du projet ou bloc figé.
|
||||
//
|
||||
// Retirer TOUS les systèmes de tête effaçait aussi ceux de l'historique
|
||||
// lui-même : le rappel des pages mémoire lues ([MEMORY PAGES REMINDER]), posé
|
||||
// par une compaction de début de tour, disparaissait à la première compaction
|
||||
// en cours de tour, et le modèle perdait la liste des pages à relire. Un
|
||||
// système de tête n'est donc gardé que s'il figurait en tête de hist,
|
||||
// l'historique dont la vue est partie : la compaction garde la tête telle
|
||||
// quelle, et la vue d'envoi n'y ajoute que ses propres messages. Seule
|
||||
// exception, le premier : InjectSkills a pu fusionner le préambule devant un
|
||||
// système de l'historique (« préambule\n\nsystème ») — on rend alors celui-ci.
|
||||
func historyFromView(view, hist []Message) []Message {
|
||||
own := map[string]int{}
|
||||
for _, m := range hist {
|
||||
if m.Role != "system" {
|
||||
break
|
||||
}
|
||||
own[msgText(m)]++
|
||||
}
|
||||
var out []Message
|
||||
i := 0
|
||||
for ; i < len(view) && view[i].Role == "system"; i++ {
|
||||
k := msgText(view[i])
|
||||
if own[k] > 0 {
|
||||
own[k]--
|
||||
out = append(out, view[i])
|
||||
continue
|
||||
}
|
||||
if i == 0 && len(hist) > 0 && hist[0].Role == "system" {
|
||||
if h := msgText(hist[0]); h != "" && own[h] > 0 && strings.HasSuffix(k, "\n\n"+h) {
|
||||
own[h]--
|
||||
out = append(out, hist[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(out, view[i:]...)
|
||||
}
|
||||
|
||||
// convState renvoie un instantané léger (pour /api/chat/state).
|
||||
func (c *Conversation) state() map[string]any {
|
||||
c.mu.Lock()
|
||||
@@ -848,6 +904,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// projet est celui, figé, de la discussion, et ses changements partent en
|
||||
// tête de ce message — après la compaction de début de tour, sous c.mu.
|
||||
sent, tools, snapTurn := c.turnView(caps, epoch, msgs, caps.Agent)
|
||||
hist := msgs // l'historique d'où part la vue : voir historyFromView
|
||||
runCtx := ctx
|
||||
if snapTurn != nil {
|
||||
runCtx = withProjSnapTurn(ctx, snapTurn)
|
||||
@@ -903,6 +960,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if 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 ».
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
@@ -913,15 +973,12 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.NewHistory != nil:
|
||||
// Compaction faite en cours de tour : elle remplace la base au lieu de
|
||||
// s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire le
|
||||
// préfixe système injecté à la volée (prompt perso + skills, fusionnés en
|
||||
// UN message system en tête) : il n'appartient pas à l'historique persisté
|
||||
// et doit rester modifiable à chaud.
|
||||
base := ev.NewHistory
|
||||
for len(base) > 0 && base[0].Role == "system" {
|
||||
base = base[1:]
|
||||
}
|
||||
newBase = append([]Message(nil), base...)
|
||||
// s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire ce
|
||||
// que la vue d'envoi a posé en tête (prompt perso + préambule, contexte
|
||||
// du projet) : il n'appartient pas à l'historique persisté et doit
|
||||
// rester modifiable à chaud. Le rappel des pages lues, lui, en fait
|
||||
// partie (historyFromView).
|
||||
newBase = historyFromView(ev.NewHistory, hist)
|
||||
case ev.Echo != nil:
|
||||
echo = ev.Echo
|
||||
case ev.Compacting != nil:
|
||||
@@ -1146,7 +1203,8 @@ func (c *Conversation) Reset() {
|
||||
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
|
||||
c.leaseWhy = nil
|
||||
c.cancel = nil
|
||||
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
|
||||
c.extLeaseClearLocked() // Reset débloque aussi un tour de la façade
|
||||
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package loki
|
||||
|
||||
// chat_envctx.go — PROJ_SNAPSHOT : la date et le dossier de travail sortent du
|
||||
// prompt système pour entrer dans le bloc projet figé de la discussion.
|
||||
//
|
||||
// Pourquoi. Le système (préambule + briefing machine) et les outils forment le
|
||||
// début de chaque requête ; tant qu'ils ne bougent pas, le moteur reprend son
|
||||
// cache. Deux valeurs y bougeaient pourtant : la date — à minuit, tout le prompt
|
||||
// d'une discussion en cours est recalculé — et le dossier de travail, propre à
|
||||
// chaque discussion — deux discussions ne partageaient donc rien, pas même le
|
||||
// système. Avec la clé, le système ne garde que des consignes (« ton dossier de
|
||||
// travail est dans ton contexte ») et les valeurs partent dans le bloc projet,
|
||||
// en tête du premier message utilisateur, avec le reste du contexte figé.
|
||||
//
|
||||
// Rien n'est mémorisé au-delà du bloc lui-même : chaque tour relit la date, le
|
||||
// dossier et la cible, et tout écart part en <context_update> dans le message
|
||||
// suivant — une ligne pour un changement de jour, le bloc entier sinon (poste
|
||||
// distant passé hors ligne, autre cible, autre dossier). Les mêmes faits, à une
|
||||
// autre place ; aucun n'est retenu par Loki plus longtemps qu'avant.
|
||||
//
|
||||
// Seul un tour de discussion avec la clé y passe (assembleTurn pose
|
||||
// caps.envInCtx). Une tâche planifiée, un sous-agent, la vérification du mode
|
||||
// Code, le terminal gardent leur système d'avant, date et dossier compris.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// envContextPrefix ouvre le message (isProjectSystem le range avec le contexte
|
||||
// du projet). Assez particulier pour qu'aucun prompt de preset ne commence ainsi.
|
||||
const envContextPrefix = "Environment (from Loki)"
|
||||
|
||||
// nodeOfflineLine : l'avertissement d'un poste distant hors ligne — dans le
|
||||
// système sans la clé, dans le bloc projet avec.
|
||||
const nodeOfflineLine = "⚠ It is currently OFFLINE: those tools will fail until it reconnects. Tell the user instead of trying repeatedly."
|
||||
|
||||
// envNow : l'horloge du bloc (remplaçable en test).
|
||||
var envNow = time.Now
|
||||
|
||||
// envDateLine : la date du jour, avec l'année et l'année périmée que la
|
||||
// consigne web du système désignait jusque-là par leur valeur.
|
||||
func envDateLine(day string) string {
|
||||
year := day
|
||||
if len(day) >= 4 {
|
||||
year = day[:4]
|
||||
}
|
||||
return "Date: " + day + " (the current year is " + year + ", not " + prevYear(year) + ")."
|
||||
}
|
||||
|
||||
// envMachineFacts : les valeurs du briefing machine qui varient — dossier de la
|
||||
// discussion, ou poste distant (nom, système, dossier, état). "" sans agent.
|
||||
// Les consignes qui les accompagnent restent dans machineSystemPrompt.
|
||||
func envMachineFacts(caps Caps) string {
|
||||
if !caps.Agent {
|
||||
return ""
|
||||
}
|
||||
if tgt, ok := nodeTargetMetaGet(); ok {
|
||||
s := "Machine: REMOTE node " + tgt.name
|
||||
if tgt.os != "" {
|
||||
s += " (" + tgt.os + ")"
|
||||
}
|
||||
if tgt.root != "" {
|
||||
s += ", working folder " + tgt.root
|
||||
}
|
||||
s += "."
|
||||
if !tgt.connected {
|
||||
s += " " + nodeOfflineLine
|
||||
}
|
||||
return s
|
||||
}
|
||||
if cwd := agentCwd(); cwd != "" {
|
||||
return "Working folder (cwd): " + cwd + "."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// envCapture : la date (si le préambule en portait une) et les valeurs machine
|
||||
// de ce tour.
|
||||
func envCapture(caps Caps) (day, facts string) {
|
||||
if caps.Agent || caps.Mem != MemOff {
|
||||
day = envNow().Format("2006-01-02")
|
||||
}
|
||||
return day, envMachineFacts(caps)
|
||||
}
|
||||
|
||||
// envContextMessage : le message du bloc, ok=false s'il n'a rien à dire.
|
||||
func envContextMessage(day, facts string) (Message, bool) {
|
||||
var lines []string
|
||||
if day != "" {
|
||||
lines = append(lines, envDateLine(day))
|
||||
}
|
||||
if facts != "" {
|
||||
lines = append(lines, facts)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
return Message{Role: "system", Content: envContextPrefix + ":\n" + strings.Join(lines, "\n")}, true
|
||||
}
|
||||
|
||||
// envDelta : la mise à jour entre l'environnement annoncé et le vivant. Un
|
||||
// changement de jour seul tient en une ligne ; tout autre écart renvoie le
|
||||
// message entier.
|
||||
func envDelta(oldDay, oldFacts, day, facts string) string {
|
||||
switch {
|
||||
case oldFacts == facts && oldDay == day:
|
||||
return ""
|
||||
case oldFacts == facts && day != "" && oldDay != "":
|
||||
return "New day — " + envDateLine(day) + "\n"
|
||||
}
|
||||
if m, ok := envContextMessage(day, facts); ok {
|
||||
return "Current version, replaces any earlier one — " + msgText(m) + "\n"
|
||||
}
|
||||
return "Environment: removed.\n"
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sysPlaceholders remplace dans un prompt système les valeurs propres à la
|
||||
// machine et au jour (date, année, hôte, utilisateur, dossiers) par des jetons
|
||||
// fixes : ce qui reste doit être identique, octet pour octet, d'une machine à
|
||||
// l'autre.
|
||||
func sysPlaceholders(s string) string {
|
||||
now := time.Now()
|
||||
year := now.Format("2006")
|
||||
host, _ := os.Hostname()
|
||||
if host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
who := ""
|
||||
if u, err := user.Current(); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
pairs := []string{
|
||||
now.Format("2006-01-02"), "<DATE>",
|
||||
scriptsDir(), "<SCRIPTS>",
|
||||
agentCwd(), "<CWD>",
|
||||
"host=" + host, "host=<HOST>",
|
||||
runtime.GOOS + "/" + runtime.GOARCH, "<OS>/<ARCH>",
|
||||
"The shell is " + agentTargetShellName(), "The shell is <SHELL>",
|
||||
}
|
||||
if who != "" {
|
||||
pairs = append(pairs, "user="+who, "user=<USER>")
|
||||
}
|
||||
pairs = append(pairs, year, "<YEAR>", prevYear(year), "<PREVYEAR>")
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
s = strings.ReplaceAll(s, pairs[i], pairs[i+1])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sysOf(t *testing.T, msgs []Message) string {
|
||||
t.Helper()
|
||||
if len(msgs) == 0 || msgs[0].Role != "system" {
|
||||
t.Fatalf("pas de système en tête : %+v", msgs)
|
||||
}
|
||||
s, _ := msgs[0].Content.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Gabarits du système SANS PROJ_SNAPSHOT, relevés sur le code d'avant le
|
||||
// déplacement de la date et du dossier de travail : ils ne doivent pas bouger
|
||||
// d'un octet (valeurs du jour et de la machine remplacées par des jetons).
|
||||
var sysGolden = map[string]string{
|
||||
"agent": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\nThe shell is <SHELL>: use its syntax.\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"internet": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\nThe shell is <SHELL>: use its syntax.\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nWeb: web_open first, then web_read/web_grep on it.\nYour training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\nToday is in <YEAR>. If a query needs a year use ONLY <YEAR>, never a remembered past year like <PREVYEAR> — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"ondemand": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is <SHELL>: use its syntax.\n\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"memseule": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n\nDate: <DATE>",
|
||||
}
|
||||
|
||||
func TestSystemeSansCleInchange(t *testing.T) {
|
||||
projSnapSetup(t, false)
|
||||
cases := map[string]Caps{
|
||||
"agent": {Agent: true, Mem: MemAlways},
|
||||
"internet": {Agent: true, Internet: true, Mem: MemAlways},
|
||||
"ondemand": {Agent: true, Mem: MemOnDemand},
|
||||
"memseule": {Mem: MemAlways},
|
||||
}
|
||||
for name, caps := range cases {
|
||||
got, _ := prepareTurn([]Message{um("salut")}, caps)
|
||||
s := sysPlaceholders(sysOf(t, got))
|
||||
if want, ok := sysGolden[name]; !ok || s != want {
|
||||
t.Errorf("%s : système changé sans la clé\nreçu %q\nattendu %q", name, s, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toolsOf : le champ tools d'une requête, tel qu'envoyé.
|
||||
func toolsOf(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
var p struct {
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(p.Tools)
|
||||
}
|
||||
|
||||
// Avec la clé : deux discussions dans deux dossiers ont le MÊME système et les
|
||||
// mêmes outils ; la date et le dossier sont dans le bloc projet de chacune.
|
||||
func TestEnvCtxSystemeCommunAuxDiscussions(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
t.Cleanup(func() { setTaskWorkspace("") })
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
var bodies []string
|
||||
dirs := []string{t.TempDir(), t.TempDir()}
|
||||
for _, d := range dirs {
|
||||
setTaskWorkspace(d)
|
||||
bodies = append(bodies, playTurn(t, m, newTestConv(), caps, "salut"))
|
||||
}
|
||||
s0, s1 := systemContent(t, bodies[0]), systemContent(t, bodies[1])
|
||||
if s0 != s1 || toolsOf(t, bodies[0]) != toolsOf(t, bodies[1]) {
|
||||
t.Fatalf("système ou outils différents d'une discussion à l'autre :\n%q\n%q", s0, s1)
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
year := today[:4]
|
||||
for _, v := range []string{today, year, dirs[0], dirs[1], "cwd="} {
|
||||
if strings.Contains(s0, v) {
|
||||
t.Fatalf("%q encore dans le système : %q", v, s0)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s0, "Your working folder (cwd) is given in your context") || !strings.Contains(s0, scriptsDir()) {
|
||||
t.Fatalf("consignes du dossier de travail perdues : %q", s0)
|
||||
}
|
||||
for i, b := range bodies {
|
||||
u := userContents(t, b)[0]
|
||||
if !strings.Contains(u, envContextPrefix+":\n"+envDateLine(today)) || !strings.Contains(u, "Working folder (cwd): "+dirs[i]+".") {
|
||||
t.Fatalf("discussion %d : date ou dossier absents du bloc : %q", i, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Changement de jour : une ligne de mise à jour, une seule fois ; système et
|
||||
// bloc figé inchangés, donc tout le début du prompt reste en cache.
|
||||
func TestEnvCtxChangementDeJour(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
day := time.Date(2026, 12, 31, 23, 50, 0, 0, time.Local)
|
||||
envNow = func() time.Time { return day }
|
||||
t.Cleanup(func() { envNow = time.Now })
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
b1 := playTurn(t, m, c, caps, "salut")
|
||||
day = day.Add(20 * time.Minute)
|
||||
b2 := playTurn(t, m, c, caps, "et maintenant ?")
|
||||
b3 := playTurn(t, m, c, caps, "merci")
|
||||
if systemContent(t, b1) != systemContent(t, b2) || systemContent(t, b2) != systemContent(t, b3) {
|
||||
t.Fatal("le système a changé avec le jour")
|
||||
}
|
||||
u1, u2, u3 := userContents(t, b1), userContents(t, b2), userContents(t, b3)
|
||||
if u1[0] != u2[0] || u2[0] != u3[0] || !strings.Contains(u1[0], "Date: 2026-12-31 (the current year is 2026, not 2025).") {
|
||||
t.Fatalf("bloc figé modifié ou sans date : %q / %q", u1[0], u2[0])
|
||||
}
|
||||
want := ctxUpdOpen
|
||||
last := u2[len(u2)-1]
|
||||
if !strings.HasPrefix(last, want) || !strings.Contains(last, "New day — Date: 2027-01-01 (the current year is 2027, not 2026).") {
|
||||
t.Fatalf("mise à jour du jour attendue : %q", last)
|
||||
}
|
||||
if u3[1] != last || strings.Contains(u3[len(u3)-1], ctxUpdOpen) {
|
||||
t.Fatalf("la mise à jour doit partir une fois, puis rester telle quelle : %q", u3)
|
||||
}
|
||||
}
|
||||
|
||||
// Tâches, sous-agents, vérification, terminal : la clé ne change rien à leur
|
||||
// système, qui garde la date et le dossier.
|
||||
func TestEnvCtxHorsDiscussionInchange(t *testing.T) {
|
||||
projSnapSetup(t, true)
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
got, _ := prepareTurn([]Message{um("tâche")}, caps)
|
||||
s := sysOf(t, got)
|
||||
if !strings.Contains(s, "\nDate: "+time.Now().Format("2006-01-02")) || !strings.Contains(s, ", cwd="+agentCwd()+".") {
|
||||
t.Fatalf("système d'une tâche modifié par la clé : %q", s)
|
||||
}
|
||||
if mp := machineSystemPrompt(caps); !strings.Contains(mp, "cwd="+agentCwd()) {
|
||||
t.Fatalf("briefing d'un sous-agent sans dossier : %q", mp)
|
||||
}
|
||||
}
|
||||
|
||||
// Poste distant passé hors ligne, autre dossier : le bloc entier repart, rien
|
||||
// n'est retenu d'avant.
|
||||
func TestEnvDelta(t *testing.T) {
|
||||
if d := envDelta("2026-10-04", "x", "2026-10-04", "x"); d != "" {
|
||||
t.Fatalf("rien de changé : %q", d)
|
||||
}
|
||||
if d := envDelta("2026-10-04", "Working folder (cwd): /a.", "2026-10-04", "Machine: REMOTE node pc. "+nodeOfflineLine); !strings.Contains(d, "Current version") || !strings.Contains(d, nodeOfflineLine) || !strings.Contains(d, "Date: 2026-10-04") {
|
||||
t.Fatalf("bloc entier attendu : %q", d)
|
||||
}
|
||||
if d := envDelta("2026-10-04", "x", "2026-10-05", "x"); d != "New day — "+envDateLine("2026-10-05")+"\n" {
|
||||
t.Fatalf("une ligne attendue : %q", d)
|
||||
}
|
||||
}
|
||||
@@ -378,11 +378,12 @@ func exportFilename(ext string) string {
|
||||
// exportBody rend la conversation selon les options. Renvoie le corps, son type
|
||||
// MIME et l'extension de fichier.
|
||||
func exportBody(o exportOpts) ([]byte, string, string, error) {
|
||||
src := exportSource() // la façade peut tenir la conversation (facade_chat.go)
|
||||
if o.Format == "json" {
|
||||
b, err := conv.ExportJSON(o)
|
||||
b, err := src.ExportJSON(o)
|
||||
return b, "application/json; charset=utf-8", "json", err
|
||||
}
|
||||
return []byte(conv.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil
|
||||
return []byte(src.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil
|
||||
}
|
||||
|
||||
// cmdExport écrit la conversation dans un fichier (ou sur la sortie standard).
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Compaction EN COURS de tour : la vue publiée perd ce que la vue d'envoi avait
|
||||
// posé en tête (préambule, prompt du preset, contexte du projet), mais garde le
|
||||
// rappel des pages lues, qui appartient à l'historique.
|
||||
func TestCompactionEnTourGardeLeRappel(t *testing.T) {
|
||||
for _, on := range []bool{false, true} {
|
||||
m := projSnapSetup(t, on)
|
||||
if err := saveSysPrompt("Réponds en français."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reminder, _ := buildReminderMessage([]string{"nas.md"})
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{reminder}
|
||||
for i := 0; i < 12; i++ {
|
||||
c.Messages = append(c.Messages, um("continue "+strings.Repeat("x", 600)), am("étape "+strings.Repeat("détail ", 300)))
|
||||
}
|
||||
n := len(m.all())
|
||||
m.mu.Lock()
|
||||
m.reply = func(k int, _ string) (int, string) {
|
||||
if k == n {
|
||||
return 400, `{"error":{"code":400,"message":"request (99999 tokens) exceeds the available context size"}}`
|
||||
}
|
||||
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "et le NAS ?")
|
||||
if len(m.all()) < n+2 {
|
||||
t.Fatalf("clé %v : pas de requête rejouée après compaction", on)
|
||||
}
|
||||
if len(c.Messages) == 0 || !reflect.DeepEqual(c.Messages[0], reminder) {
|
||||
t.Fatalf("clé %v : rappel des pages lues perdu : %+v", on, c.Messages[0])
|
||||
}
|
||||
for _, msg := range c.Messages[1:] {
|
||||
if msg.Role == "system" {
|
||||
t.Fatalf("clé %v : système injecté rangé dans l'historique : %q", on, msgText(msg))
|
||||
}
|
||||
}
|
||||
if len(c.Messages) >= 25 {
|
||||
t.Fatalf("clé %v : compaction non rangée (%d messages)", on, len(c.Messages))
|
||||
}
|
||||
if err := saveSysPrompt(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryFromView(t *testing.T) {
|
||||
rem := Message{Role: "system", Content: memReminderPrefix + " Memory pages you read earlier: a.md"}
|
||||
sys := Message{Role: "system", Content: "/sys perso"}
|
||||
u := um("q")
|
||||
cases := []struct {
|
||||
name string
|
||||
view, hist []Message
|
||||
want []Message
|
||||
}{
|
||||
{"injectés seuls", []Message{{Role: "system", Content: "préambule"}, {Role: "system", Content: projectContextPrefix + " x"}, u}, []Message{u}, []Message{u}},
|
||||
{"rappel gardé", []Message{{Role: "system", Content: "préambule"}, {Role: "system", Content: projectContextPrefix + " x"}, rem, u}, []Message{rem, u}, []Message{rem, u}},
|
||||
{"système fusionné rendu", []Message{{Role: "system", Content: "préambule\n\n/sys perso"}, u}, []Message{sys, u}, []Message{sys, u}},
|
||||
{"sans système", []Message{u}, []Message{u}, []Message{u}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := historyFromView(tc.view, tc.hist); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("%s : %+v, attendu %+v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,6 +347,9 @@ var (
|
||||
|
||||
const pageCacheTTL = 10 * time.Minute
|
||||
|
||||
// webNow : l'horloge du cache des pages (fixée par le journal de parité).
|
||||
var webNow = time.Now
|
||||
|
||||
type fetchOptions struct {
|
||||
force bool
|
||||
actions []string
|
||||
@@ -371,7 +374,7 @@ func findCached(rawURL string) *cacheEntry {
|
||||
var best *cacheEntry
|
||||
for k, v := range pageCache {
|
||||
if k == u || strings.HasPrefix(k, u+"#") {
|
||||
if time.Since(v.fetchedAt) > pageCacheTTL {
|
||||
if webNow().Sub(v.fetchedAt) > pageCacheTTL {
|
||||
continue
|
||||
}
|
||||
if best == nil || v.fetchedAt.After(best.fetchedAt) {
|
||||
@@ -388,7 +391,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
|
||||
pageCacheMu.Lock()
|
||||
cached := pageCache[key]
|
||||
pageCacheMu.Unlock()
|
||||
if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL {
|
||||
if !opts.force && cached != nil && webNow().Sub(cached.fetchedAt) < pageCacheTTL {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
@@ -418,7 +421,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
|
||||
if strings.TrimSpace(md) == "" {
|
||||
return nil, fmt.Errorf("page vide")
|
||||
}
|
||||
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()}
|
||||
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: webNow()}
|
||||
pageCacheMu.Lock()
|
||||
pageCache[key] = entry
|
||||
pageCacheMu.Unlock()
|
||||
@@ -493,6 +496,10 @@ func decodeHTMLEntities(s string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// ddgHTMLBase : le point d'entrée HTML de DuckDuckGo lu par le moteur intégré
|
||||
// (goSearch ; un faux pour les tests).
|
||||
var ddgHTMLBase = "https://html.duckduckgo.com"
|
||||
|
||||
// decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE
|
||||
func decodeUddg(raw string) string {
|
||||
s := raw
|
||||
|
||||
@@ -297,7 +297,7 @@ func cmdInternet(args []string) error {
|
||||
if err := setInternetEnabled(true); err != nil {
|
||||
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":
|
||||
if err := setInternetEnabled(false); err != nil {
|
||||
return err
|
||||
@@ -352,7 +352,7 @@ func cmdInternet(args []string) error {
|
||||
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
||||
if webEngine() == engineGo {
|
||||
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
|
||||
}
|
||||
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
|
||||
@@ -366,7 +366,7 @@ func cmdInternet(args []string) error {
|
||||
reach = green("joignable")
|
||||
}
|
||||
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:
|
||||
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
|
||||
}
|
||||
|
||||
@@ -407,36 +407,65 @@ func TestKeptImagesDebutDeTour(t *testing.T) {
|
||||
}
|
||||
|
||||
// NUDGE_IN_TOOL : seulement avec la clé, sur le moteur local, si la sonde a dit
|
||||
// « instable » pour CE modèle.
|
||||
// « instable » pour CE modèle et pour la forme de CETTE requête.
|
||||
func TestNudgeInToolOn(t *testing.T) {
|
||||
freshTplProbe(t)
|
||||
cfg := map[string]string{"MODEL": "/models/qwen35.gguf"}
|
||||
tools := []Tool{readTool(), grepTool()}
|
||||
kwargs := map[string]any{"enable_thinking": true}
|
||||
set := func(r tplProbeResult) {
|
||||
tplProbeMu.Lock()
|
||||
tplProbeLast = &r
|
||||
tplProbeKeepShape(newTplShape(tools, kwargs, "").hash(), r)
|
||||
tplProbeMu.Unlock()
|
||||
}
|
||||
on := func(cfg map[string]string, ep chatEndpoint) bool { return nudgeInToolOn(cfg, ep, tools, kwargs, "") }
|
||||
set(tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo})
|
||||
if nudgeInToolOn(cfg, chatEndpoint{}) {
|
||||
if on(cfg, chatEndpoint{}) {
|
||||
t.Fatal("actif sans la clé")
|
||||
}
|
||||
cfg["NUDGE_IN_TOOL"] = "on"
|
||||
if !nudgeInToolOn(cfg, chatEndpoint{}) {
|
||||
if !on(cfg, chatEndpoint{}) {
|
||||
t.Fatal("inactif avec la clé sur un gabarit instable")
|
||||
}
|
||||
if nudgeInToolOn(cfg, chatEndpoint{External: true}) {
|
||||
if on(cfg, chatEndpoint{External: true}) {
|
||||
t.Fatal("actif sur un preset externe")
|
||||
}
|
||||
// Le verdict « instable » d'une forme ne vaut pas pour une autre : autres
|
||||
// outils, autres kwargs ou autre niveau de raisonnement — inconnu, repli.
|
||||
for name, other := range map[string]func() bool{
|
||||
"autres outils": func() bool { return nudgeInToolOn(cfg, chatEndpoint{}, []Tool{readTool()}, kwargs, "") },
|
||||
"autres kwargs": func() bool {
|
||||
return nudgeInToolOn(cfg, chatEndpoint{}, tools, map[string]any{"enable_thinking": false}, "")
|
||||
},
|
||||
"autre effort": func() bool { return nudgeInToolOn(cfg, chatEndpoint{}, tools, kwargs, "high") },
|
||||
} {
|
||||
if other() {
|
||||
t.Errorf("%s : actif sur le verdict d'une autre forme", name)
|
||||
}
|
||||
}
|
||||
for _, r := range []tplProbeResult{
|
||||
{Model: "qwen35.gguf", PrefixStable: tplUnknown},
|
||||
{Model: "qwen35.gguf", PrefixStable: tplYes},
|
||||
{Model: "autre.gguf", PrefixStable: tplNo},
|
||||
} {
|
||||
set(r)
|
||||
if nudgeInToolOn(cfg, chatEndpoint{}) {
|
||||
if on(cfg, chatEndpoint{}) {
|
||||
t.Fatalf("actif pour %+v", r)
|
||||
}
|
||||
}
|
||||
// La dernière forme sondée (tplProbeLast) ne décide plus : une autre forme
|
||||
// sondée « stable » ensuite ne change rien au verdict de la nôtre, et
|
||||
// inversement.
|
||||
set(tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo})
|
||||
tplProbeMu.Lock()
|
||||
last := tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplYes}
|
||||
tplProbeLast = &last
|
||||
tplProbeKeepShape(newTplShape(nil, kwargs, "").hash(), last)
|
||||
tplProbeMu.Unlock()
|
||||
if !on(cfg, chatEndpoint{}) {
|
||||
t.Error("verdict de notre forme écrasé par la dernière forme sondée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeIntoTool(t *testing.T) {
|
||||
@@ -468,11 +497,17 @@ func TestRappelDeBudgetDansLeResultat(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Le verdict de la sonde pour la forme exacte de la requête du tour : mêmes
|
||||
// outils, mêmes chat_template_kwargs, même niveau de raisonnement.
|
||||
caps := Caps{Agent: true}
|
||||
_, effort, _, kwargs := turnReasoning(ReadConfig())
|
||||
tplProbeMu.Lock()
|
||||
tplProbeLast = &tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}
|
||||
r := tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}
|
||||
tplProbeLast = &r
|
||||
tplProbeKeepShape(newTplShape(EnabledTools(caps), kwargs, effort).hash(), r)
|
||||
tplProbeMu.Unlock()
|
||||
reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop)
|
||||
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, caps, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -486,6 +521,32 @@ func TestRappelDeBudgetDansLeResultat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Le verdict « instable » d'une AUTRE forme (dernière sondée, ici sans
|
||||
// outils) ne décide pas pour ce tour : rappel à part, comme sans la clé.
|
||||
func TestRappelDeBudgetVerdictDUneAutreForme(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
freshTplProbe(t)
|
||||
for k, v := range map[string]string{"AGENT_BUDGET": "1", "NUDGE_IN_TOOL": "on", "MODEL": "/models/qwen35.gguf"} {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, effort, _, kwargs := turnReasoning(ReadConfig())
|
||||
tplProbeMu.Lock()
|
||||
r := tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}
|
||||
tplProbeLast = &r
|
||||
tplProbeKeepShape(newTplShape(nil, kwargs, effort).hash(), r)
|
||||
tplProbeMu.Unlock()
|
||||
reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop)
|
||||
if _, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := reqMessages(t, reqs()[1])
|
||||
if last := sent[len(sent)-1]; last.Role != "user" {
|
||||
t.Fatalf("rappel glissé dans le résultat sur le verdict d'une autre forme : %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// En cours de tour, au seuil : les images gardées partent d'abord, et le
|
||||
// résumé n'est pas demandé si leur retrait suffit.
|
||||
func TestKeepImagesRetireesEnCoursDeTour(t *testing.T) {
|
||||
|
||||
@@ -15,6 +15,10 @@ import (
|
||||
// L'IA y range ce qu'elle veut retenir entre les sessions (préférences,
|
||||
// décisions, procédures, infos projet). Quatre outils : mem_search, mem_read,
|
||||
// mem_add, mem_edit.
|
||||
//
|
||||
// Port Rust des outils (lot 40) : rust/loki-rs/src/memory/tools.rs, employé
|
||||
// par les tours de la façade quand elle tient la mémoire (LOKI_MEM=rust) ;
|
||||
// journal de parité dans parity_mem_tools_test.go.
|
||||
|
||||
type MemPage struct {
|
||||
Name string // nom de fichier sans le dossier (ex: "docker-notes.md")
|
||||
|
||||
@@ -245,6 +245,8 @@ func prewarmNearMidnight(now time.Time) bool {
|
||||
|
||||
// Points d'accroche remplaçables par les tests.
|
||||
var (
|
||||
// prewarmNow : l'heure du garde-fou de minuit.
|
||||
prewarmNow = time.Now
|
||||
// prewarmAsync : false = le préchauffage tourne dans l'appelant (tests).
|
||||
prewarmAsync = true
|
||||
// prewarmSlots : nombre de slots du moteur local (/props total_slots).
|
||||
@@ -265,10 +267,16 @@ var (
|
||||
)
|
||||
|
||||
// prewarmKick : un déclencheur. Sans la clé, rien — ni goroutine, ni requête.
|
||||
// Quand la façade tient la conversation, le déclencheur lui part
|
||||
// (facade_prewarm.go) : c'est elle qui a l'historique.
|
||||
func (c *Conversation) prewarmKick(why string) {
|
||||
if prewarmMode(ReadConfig()) == "" {
|
||||
return
|
||||
}
|
||||
if facadeChat() {
|
||||
go func() { _ = facadePost("/internal/facade/v1/chat/prewarm", map[string]any{"why": why}) }()
|
||||
return
|
||||
}
|
||||
if prewarmAsync {
|
||||
go c.prewarm(why)
|
||||
return
|
||||
@@ -375,7 +383,7 @@ func (c *Conversation) prewarm(why string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(time.Now()) {
|
||||
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(prewarmNow()) {
|
||||
return
|
||||
}
|
||||
if !healthCheck() {
|
||||
|
||||
@@ -79,6 +79,11 @@ func (m *moteurPrewarm) completions() []string {
|
||||
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
|
||||
// préchauffage synchrone. keys : réglages posés.
|
||||
func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm {
|
||||
// Le préchauffage se tait dans les dix minutes avant minuit : midi ici,
|
||||
// pour que les tests ne dépendent pas de l'heure à laquelle ils tournent.
|
||||
oldNow := prewarmNow
|
||||
prewarmNow = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local) }
|
||||
t.Cleanup(func() { prewarmNow = oldNow })
|
||||
t.Helper()
|
||||
withWorkspace(t)
|
||||
activeProjMu.Lock()
|
||||
|
||||
@@ -22,12 +22,14 @@ package loki
|
||||
// - rien n'est perdu : lignes ajoutées, modifiées, retirées pour les données
|
||||
// faites de lignes (pages, trackers), texte COMPLET pour la prose
|
||||
// (description, AGENTS.md). Une ligne fixe du système dit que ces blocs
|
||||
// viennent de Loki et que le plus récent l'emporte.
|
||||
// viennent de Loki et que le plus récent l'emporte ;
|
||||
// - la date du jour et le dossier de travail quittent le système pour ce bloc
|
||||
// (chat_envctx.go) : un changement de jour devient une ligne de mise à jour.
|
||||
//
|
||||
// Le bloc est rafraîchi — copie neuve, blocs <context_update> retirés de tout
|
||||
// l'historique — seulement quand le début du prompt change de toute façon :
|
||||
// compaction (début, fin de tour, manuelle, en cours de tour, réduction forcée),
|
||||
// système ou outils modifiés (date du jour, réglages, mode), cache froid
|
||||
// système ou outils modifiés (réglages, mode, cible), cache froid
|
||||
// (redémarrage de Loki, changement de modèle, de preset ou de réglage moteur),
|
||||
// projet, nom, mode mémoire, mode Code ou dépôt changés, textes des en-têtes
|
||||
// changés. Et quand les mises à jour accumulées dépassent un seuil : au-delà, le
|
||||
@@ -66,7 +68,7 @@ func projSnapEnabled(cfg map[string]string) bool {
|
||||
// Textes propres au bloc figé. Leur empreinte entre dans la clé de validité :
|
||||
// une mise à jour de Loki qui les reformule rafraîchit les instantanés persistés.
|
||||
const (
|
||||
projSnapFormat = "1"
|
||||
projSnapFormat = "2"
|
||||
memIndexFrozenTail = " Later <context_update> blocks from Loki update it."
|
||||
trackerFrozenHead = " — your trackers, each with its latest point at that time; later <context_update> blocks from Loki replace these lines. " +
|
||||
"To say which trackers exist or give a latest value, use this list with those updates — do NOT call tracker with no arguments to list them. " +
|
||||
@@ -89,6 +91,8 @@ func projSnapNow() string { return time.Now().Format("2006-01-02 15:04") }
|
||||
// simple hash : c'est contre lui qu'on calcule la mise à jour suivante, y
|
||||
// compris après un redémarrage.
|
||||
type projState struct {
|
||||
Day string `json:"day,omitempty"` // date annoncée (chat_envctx.go)
|
||||
Env string `json:"env,omitempty"` // valeurs machine annoncées
|
||||
Code string `json:"code,omitempty"` // consignes du dépôt, message complet
|
||||
Desc string `json:"desc,omitempty"` // description du projet
|
||||
MemOn bool `json:"mem_on,omitempty"` // index mémoire présent
|
||||
@@ -122,9 +126,15 @@ type projLive struct {
|
||||
|
||||
// projSnapCapture lit le contexte projet UNE fois et en tire le bloc (même
|
||||
// ordre que l'assemblage d'un tour : consignes du dépôt, description, index
|
||||
// mémoire, trackers) et son état.
|
||||
// mémoire, trackers — précédés ici de la date et du dossier de travail, que le
|
||||
// système ne porte plus avec la clé) et son état.
|
||||
func projSnapCapture(caps Caps, asOf string) projLive {
|
||||
var lv projLive
|
||||
// Date et dossier de travail, sortis du système (chat_envctx.go).
|
||||
lv.state.Day, lv.state.Env = envCapture(caps)
|
||||
if m, ok := envContextMessage(lv.state.Day, lv.state.Env); ok {
|
||||
lv.msgs = append(lv.msgs, m)
|
||||
}
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
lv.msgs = append(lv.msgs, m)
|
||||
lv.state.Code, _ = m.Content.(string)
|
||||
@@ -224,7 +234,7 @@ func projSnapKey(caps Caps, cfg map[string]string) string {
|
||||
w(d)
|
||||
w(agentCwd())
|
||||
}
|
||||
for _, s := range []string{memIndexFrozenTail, trackerFrozenHead, ctxUpdOpen, ctxUpdClose, projSnapSystemLine} {
|
||||
for _, s := range []string{envContextPrefix, nodeOfflineLine, memIndexFrozenTail, trackerFrozenHead, ctxUpdOpen, ctxUpdClose, projSnapSystemLine} {
|
||||
w(s)
|
||||
}
|
||||
return fmt.Sprintf("%016x", h.Sum64())
|
||||
@@ -268,6 +278,7 @@ func projDelta(old projState, lv projLive, asOf string) string {
|
||||
full := func(m Message) {
|
||||
b.WriteString("Current version, replaces any earlier one — " + msgText(m) + "\n")
|
||||
}
|
||||
b.WriteString(envDelta(old.Day, old.Env, cur.Day, cur.Env))
|
||||
if old.Code != cur.Code {
|
||||
if cur.Code == "" {
|
||||
b.WriteString("Repository instructions: removed.\n")
|
||||
@@ -569,6 +580,9 @@ func (c *Conversation) assembleTurn(caps Caps, epoch int, msgs []Message, inject
|
||||
return sent, tools, nil
|
||||
}
|
||||
|
||||
// Date et dossier de travail : dans le bloc, plus dans le système
|
||||
// (chat_envctx.go). Cette copie de caps ne sert qu'à ce tour de discussion.
|
||||
caps.envInCtx = true
|
||||
// Lectures (disque, base, git) hors du verrou de la conversation.
|
||||
asOf := projSnapNow()
|
||||
live := projSnapCapture(caps, asOf)
|
||||
|
||||
Loaded 100 of 555 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user