mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ee04037b | ||
|
|
cf83f5f629 | ||
|
|
9f6bd725e6 | ||
|
|
056f307983 | ||
|
|
667e340e57 | ||
|
|
c3005f5b56 | ||
|
|
9b2e974942 | ||
|
|
4f34694400 | ||
|
|
3dff7e0e04 | ||
|
|
f1f6cbe4ea | ||
|
|
eaf5e313d9 | ||
|
|
7dd319a058 | ||
|
|
9263df404d | ||
|
|
38b488515a | ||
|
|
72ba8e0fb6 | ||
|
|
e14ead98c4 | ||
|
|
26fc348506 | ||
|
|
d6023ef8a0 | ||
|
|
18a723a671 | ||
|
|
80b7793773 | ||
|
|
3ff56e01db | ||
|
|
3a6ad6f23b | ||
|
|
7f49310b7f | ||
|
|
dd27b81af0 | ||
|
|
c72467ce4c | ||
|
|
3565b594ea | ||
|
|
d028e81ce2 | ||
|
|
0e841c26bd | ||
|
|
c4f051a0e9 | ||
|
|
2bf388f7b7 | ||
|
|
a42c184ed2 | ||
|
|
9aa426a389 | ||
|
|
2de20e0ab2 | ||
|
|
f7f1a30d08 | ||
|
|
0a02604968 | ||
|
|
c6c5dc2b7e | ||
|
|
03eb56aee3 | ||
|
|
76f5697487 | ||
|
|
aebecd75bd | ||
|
|
cf8d1cfaaa | ||
|
|
b955181bb9 | ||
|
|
15495431fc | ||
|
|
d5632e3595 | ||
|
|
dfafca6fb2 | ||
|
|
2e0356cd6c | ||
|
|
a745d2399e | ||
|
|
ad87f2fc9f | ||
|
|
7b754c0cb6 | ||
|
|
9ccdc021cb | ||
|
|
5e36b1cf82 | ||
|
|
2bbf0ba76d | ||
|
|
8171899ac1 | ||
|
|
199d7f1e99 | ||
|
|
7f8c5d360f | ||
|
|
2db0ecd431 | ||
|
|
48b8193dca | ||
|
|
58c2da1302 | ||
|
|
ae0e181711 | ||
|
|
6aafeeea98 | ||
|
|
f54c304019 | ||
|
|
51c791268d | ||
|
|
04137411fa | ||
|
|
f7a4ee71e7 | ||
|
|
1db987844e | ||
|
|
f75f3cf27d | ||
|
|
38802d9e21 | ||
|
|
2c36fa6d2c | ||
|
|
4a1054c1b7 | ||
|
|
626227f2f2 | ||
|
|
4602614be0 | ||
|
|
f117a466af | ||
|
|
97378da7af | ||
|
|
e12f07426f | ||
|
|
20375d0e4c | ||
|
|
b893d1a81f | ||
|
|
513aca1c7b | ||
|
|
f1075030dd | ||
|
|
c4e399e101 | ||
|
|
c32fd40f1a | ||
|
|
382c5dbdce | ||
|
|
fa69dac77f | ||
|
|
e9ec8ae3a8 | ||
|
|
74fb1fc781 | ||
|
|
91d1796615 | ||
|
|
00b2350fb9 |
No files matched your search
@@ -26,7 +26,31 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
|
||||
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
|
||||
patterns, table languages.json) est reprise
|
||||
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales.
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
|
||||
ajoutent depuis d'autres idées reprises d'OpenFox et réimplémentées ici :
|
||||
les **sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans
|
||||
son propre contexte), la **vérification déclenchée quand le builder a
|
||||
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
|
||||
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
|
||||
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
|
||||
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
|
||||
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
|
||||
qui garde l'état du mode Code.
|
||||
|
||||
## Reprises depuis l'amont après le fork
|
||||
|
||||
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
|
||||
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
|
||||
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
|
||||
présent dans l'image) et la **préparation des images** envoyées au modèle
|
||||
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
|
||||
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
|
||||
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
|
||||
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
|
||||
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
|
||||
**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.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -122,6 +122,17 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_HOME` | données (volume) | `/data` |
|
||||
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
|
||||
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
|
||||
| `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 | — |
|
||||
|
||||
**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
|
||||
toute requête venue d'un autre site : une page web ouverte dans un navigateur
|
||||
du réseau ne peut plus piloter Loki en douce (ni par DNS rebinding). Derrière
|
||||
un reverse proxy (`loki.mondomaine.fr`), définir une clé
|
||||
(`docker exec -it loki loki set-web-key`) ou lister le nom dans
|
||||
`LOKI_TRUSTED_HOSTS`.
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
@@ -137,7 +148,9 @@ et modèles compris.
|
||||
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
|
||||
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
|
||||
| `/data/models/` | modèles téléchargés depuis l'interface |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell ; chiffrées si le chiffrement est actif |
|
||||
| `/data/backups/memory/` | snapshots de la mémoire pris avant chaque opération qui touche à tout |
|
||||
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
|
||||
| `/data/workspace/` | racine du dossier de travail de l'agent |
|
||||
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
|
||||
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
|
||||
@@ -154,6 +167,27 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
|
||||
emplacements différents dès que le partage n'est pas en cache-only ou que le
|
||||
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
|
||||
|
||||
**Performance sur Unraid.** `/mnt/user/…` passe par `shfs`, la couche FUSE des
|
||||
partages : chaque écriture de `loki.db` et chaque page d'un gros modèle relue
|
||||
depuis le disque (MoE plus gros que la RAM, mmappé) traverse un démon en espace
|
||||
utilisateur, ce qui ralentit le décodage. Loki le détecte au démarrage
|
||||
(`fuse.shfs` dans `/proc/self/mountinfo`) et l'affiche en encart d'information —
|
||||
rien n'est perdu ni altéré. Pour l'éviter :
|
||||
|
||||
- **recommandé** — partages en *Exclusive access* (Unraid 6.12+). Ce n'est
|
||||
pas une case mais un état : *Global Share Settings* → *Permit exclusive
|
||||
shares* = Yes, puis le partage `appdata` (et celui des modèles) entièrement
|
||||
sur **un** pool — mover d'abord vers le pool, ensuite *Secondary storage* =
|
||||
None — jusqu'à lire *Exclusive access : Yes* sur la page du partage.
|
||||
Redémarre le conteneur : le chemin `/mnt/user/…` ne change pas, FUSE est
|
||||
court-circuité ;
|
||||
- **avancé** — mappe `/mnt/<ton-pool>/appdata/loki/…` (ex. `/mnt/cache` si ton
|
||||
pool s'appelle `cache`), avec une migration manuelle : `Compose Down`,
|
||||
`rsync -a` de l'ancien dossier vers le nouveau, puis modification des deux
|
||||
lignes `volumes`. Un `/mnt/<pool>` inexistant atterrit dans la RAM d'Unraid
|
||||
(données perdues au redémarrage, serveur saturé par un modèle de 80 Go), et
|
||||
changer le chemin sans migrer donne un `/data` vide.
|
||||
|
||||
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
|
||||
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
|
||||
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
|
||||
@@ -175,11 +209,29 @@ Héritées d'AJEAN :
|
||||
|
||||
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
|
||||
conversations. La **vision** demande un modèle multimodal *et* son projecteur
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle). Toute image
|
||||
envoyée au modèle (pièce jointe, `see_image`, capture) est d'abord
|
||||
**redressée** — l'orientation EXIF d'une photo de téléphone est cuite dans
|
||||
les pixels, sinon le projecteur, qui ignore l'EXIF, la voyait couchée — et
|
||||
**ramenée sous 1568 px** de grand côté, taille au-delà de laquelle le base64
|
||||
grossit sans rien apprendre de plus au modèle.
|
||||
- **Mémoire persistante** (`memory off|ondemand|always`).
|
||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Contrôle du navigateur** (`computer on`, ou *Réglages → Contrôle du
|
||||
navigateur*) : l'IA **pilote** un Chromium — celui de Playwright, déjà dans
|
||||
l'image — par le protocole DevTools. Elle ouvre une page, en reçoit les
|
||||
éléments interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit
|
||||
par numéro : `browser_open`, `browser_snapshot`, `browser_find`,
|
||||
`browser_click`, `browser_type`, `browser_key`, `browser_scroll`. Aucune
|
||||
vision requise — ça marche avec de petits modèles texte. Avec un projecteur
|
||||
`mmproj` chargé s'ajoutent `browser_screenshot` (image quadrillée tous les
|
||||
100 px) et `browser_click_xy`, pour ce que l'arbre d'accessibilité ne montre
|
||||
pas (canvas, bandeau en iframe). Ce sont des **actions réelles** sur le web :
|
||||
l'interrupteur est distinct de l'accès internet et n'agit qu'en **mode
|
||||
agent**, au même niveau de confiance que `bash`. La session navigateur est
|
||||
unique et réutilisée entre les appels ; couper l'interrupteur la ferme.
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
|
||||
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
|
||||
@@ -195,7 +247,48 @@ Héritées d'AJEAN :
|
||||
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
|
||||
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
|
||||
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt.
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de
|
||||
tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier
|
||||
`/data/scripts` lancé directement, **sans charger le modèle ni consommer un
|
||||
token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce
|
||||
dossier vit **hors du workspace** : vider une discussion, ou la supprimer,
|
||||
n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`,
|
||||
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
|
||||
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
|
||||
pilote que les tâches de ce projet.
|
||||
- **Notifications** (Web Push) : le serveur prévient le navigateur **à la fin
|
||||
d'une réponse et à la fin d'une tâche planifiée**, même l'app fermée ou le
|
||||
téléphone verrouillé — c'est le serveur qui pousse, pas la page (un onglet
|
||||
caché relâche son flux). L'interrupteur est dans *Réglages → Mode agent*, à
|
||||
armer **sur chaque appareil** (l'abonnement appartient au navigateur). Exige
|
||||
**HTTPS** ou localhost ; sur iPhone, il faut d'abord ajouter Loki à l'écran
|
||||
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
|
||||
le reste sous `/data` ; le corps de la notification reste générique (aucun
|
||||
extrait de réponse), puisqu'elle transite par Apple ou Google.
|
||||
- **Chiffrement de la mémoire** (*Réglages → Mémoire*) : pages mémoire,
|
||||
discussions, blocs archivés au compactage et trackers chiffrés en
|
||||
**AES-256-GCM** sur le disque. Chiffrement à enveloppe : une clé de données
|
||||
(DEK) tirée une fois, enfermée dans un coffre par une clé dérivée en
|
||||
**Argon2id**. Ce qui ouvre le coffre : la **clé de pilotage de l'appareil**
|
||||
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre
|
||||
seul) ou une **clé de récupération** affichée une seule fois à l'activation.
|
||||
La DEK ne vit qu'en RAM : après un redémarrage à froid, la mémoire est
|
||||
verrouillée jusqu'à ce qu'un navigateur se reconnecte — verrouillée, Loki
|
||||
n'écrit jamais de clair par-dessus du chiffré, il refuse d'écrire. Règle
|
||||
tenue partout : **rien n'est supprimé avant que son remplaçant ait été relu
|
||||
et vérifié**, un **snapshot** est pris avant chaque bascule, et une migration
|
||||
interrompue reprend au démarrage.
|
||||
- **Sauvegarde chiffrée en fichier** : *exporter* télécharge un paquet scellé
|
||||
(mémoire, presets, réglages) que *importer* rejoue sur un autre serveur avec
|
||||
la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de
|
||||
l'amont sans son relais : ici rien ne part sur un service tiers, le fichier
|
||||
reste chez toi.
|
||||
- **Interface en anglais** (*Réglages → Apparence → Langue*) : la source reste
|
||||
française et « English » applique un dictionnaire sur la coque — navigation,
|
||||
intitulés, boutons, interrupteurs. Ce qui n'y figure pas **reste en
|
||||
français** plutôt que d'afficher une clé technique, et le fil de discussion
|
||||
n'est jamais touché : c'est ton contenu. Le dictionnaire s'enrichit sans
|
||||
toucher au reste de l'interface (`ui/src/js/28-i18n.js`).
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
|
||||
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
|
||||
@@ -228,7 +321,15 @@ Ajoutées par ce fork :
|
||||
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
|
||||
jamais bascule automatique. Conception reprise
|
||||
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
|
||||
`NOTICE.md`.
|
||||
`NOTICE.md`. **Sous-agents** : l'outil `subagent` délègue une recherche
|
||||
(`explorer`), une relecture (`code-reviewer`) ou un découpage (`planner`) à
|
||||
un rôle qui travaille dans **son propre contexte** et ne rend que sa réponse.
|
||||
Sur un modèle local, c'est ce qui sauve la fenêtre : « trouve où est géré le
|
||||
cache » coûte dix lectures de fichiers, qui resteraient sinon dans
|
||||
l'historique jusqu'à la compaction alors que seule la réponse comptait. Tous
|
||||
les rôles délégués sont en **lecture seule** — ce qui modifie le dépôt reste
|
||||
dans le fil principal, sous tes yeux — et un sous-agent ne peut pas en
|
||||
appeler un autre.
|
||||
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
|
||||
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
|
||||
playwright, context7, github…) avec leur commande déjà renseignée, classés par
|
||||
|
||||
+74
-34
@@ -1,50 +1,90 @@
|
||||
# Loki 0.12.0
|
||||
# Loki 0.14.0
|
||||
|
||||
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
|
||||
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
|
||||
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.
|
||||
|
||||
## Le sélecteur de l'en-tête charge vraiment un modèle
|
||||
## Le contexte ne bloque plus la discussion
|
||||
|
||||
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
|
||||
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
|
||||
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
|
||||
créé, le sélecteur n'offrait aucun choix.
|
||||
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.
|
||||
|
||||
## Dictée vocale
|
||||
- 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.
|
||||
|
||||
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
|
||||
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
|
||||
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
|
||||
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
|
||||
## Presets : API compatible OpenAI
|
||||
|
||||
## Cartes raisonnement/outils : taille fixe, défilement qui suit
|
||||
Les presets d'API externe gagnent ce qui leur manquait :
|
||||
|
||||
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
|
||||
génération — remonter lire le début est respecté. Les cartes d'outil suivent
|
||||
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## Mesures
|
||||
## Mode Code
|
||||
|
||||
- La carte du temps total affiche la **vitesse moyenne de la conversation**
|
||||
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
|
||||
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
|
||||
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
|
||||
seulement les octets lus).
|
||||
- **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.
|
||||
|
||||
## Interface
|
||||
## Tâches et chat
|
||||
|
||||
- Discussions triées par date de **création** (récentes en tête) : une
|
||||
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
|
||||
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
|
||||
Performance.
|
||||
- **« 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.
|
||||
|
||||
## Rappel 0.11.0
|
||||
## Sécurité et données
|
||||
|
||||
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
|
||||
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
|
||||
l'interface ; tâches planifiées reprises de l'amont.
|
||||
- **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.
|
||||
|
||||
## Moteur
|
||||
|
||||
- `--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.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,14 +2,14 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 12,
|
||||
"Patch": 3,
|
||||
"Minor": 14,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 12,
|
||||
"Patch": 3,
|
||||
"Minor": 14,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||
"OriginalFilename": "loki.exe",
|
||||
"ProductName": "Loki",
|
||||
"ProductVersion": "0.12.3",
|
||||
"ProductVersion": "0.14.0",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
@@ -34,4 +34,4 @@
|
||||
"CharsetID": "04B0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,32 @@ services:
|
||||
# lancement). Ex. LOKI_MODEL=Qwen3-14B-Q4_K_M.gguf après dépôt du
|
||||
# fichier dans /mnt/user/appdata/loki/models.
|
||||
- LOKI_MODEL=
|
||||
# Accès par NOM DE DOMAINE (reverse proxy, ex. loki.mondomaine.fr) sans
|
||||
# clé de pilotage : liste ici le(s) nom(s), séparés par des virgules —
|
||||
# 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=
|
||||
volumes:
|
||||
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
|
||||
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
|
||||
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
|
||||
# démon en espace utilisateur. Loki le signale au démarrage (encart
|
||||
# d'information, rien n'est perdu). Deux remèdes, au choix :
|
||||
# - Recommandé (Unraid 6.12+) : partages en « Exclusive access ».
|
||||
# Ce n'est pas une case mais un état : Settings -> Global Share
|
||||
# Settings -> « Permit exclusive shares » = Yes, puis le partage
|
||||
# appdata (et celui des modèles) entièrement sur UN pool — mover
|
||||
# d'abord vers le pool, ensuite Secondary storage = None. La page du
|
||||
# partage affiche alors « Exclusive access : Yes ». Redémarre le
|
||||
# conteneur : le chemin /mnt/user reste le même, FUSE est
|
||||
# court-circuité.
|
||||
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
|
||||
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
|
||||
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
|
||||
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
|
||||
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
|
||||
# et un modèle de 80 Go peut saturer la mémoire du serveur.
|
||||
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
|
||||
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
|
||||
- /mnt/user/appdata/loki/data:/data
|
||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||
|
||||
@@ -22,6 +22,9 @@ services:
|
||||
- LOKI_MODEL=${LOKI_MODEL:-}
|
||||
- LOKI_CTX=${LOKI_CTX:-}
|
||||
- LOKI_NGL=${LOKI_NGL:-}
|
||||
# Accès par nom de domaine (reverse proxy) sans clé de pilotage : nom(s)
|
||||
# autorisé(s), séparés par des virgules — sinon l'API répond 403.
|
||||
- LOKI_TRUSTED_HOSTS=${LOKI_TRUSTED_HOSTS:-}
|
||||
volumes:
|
||||
# /data = LOKI_HOME : config, base bbolt, mémoire, workspace, modèles téléchargés
|
||||
- ./data:/data
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
codeberg.org/readeck/go-readability/v2 v2.1.2
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/caddyserver/certmagic v0.25.4
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/getlantern/systray v1.2.2
|
||||
@@ -13,6 +14,7 @@ require (
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,6 +30,7 @@ require (
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/itlightning/dateparse v0.2.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
@@ -45,7 +48,6 @@ require (
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
@@ -6,6 +6,8 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
|
||||
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
|
||||
@@ -39,8 +41,10 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
@@ -84,6 +88,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
@@ -102,24 +107,86 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -10,6 +10,7 @@ Method — in this order:
|
||||
Rules:
|
||||
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
|
||||
- Write each file COMPLETE in a single write call. Many small writes waste turns.
|
||||
- You may NOT mark a criterion passed — the verification pass does that.
|
||||
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
|
||||
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
|
||||
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
|
||||
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
|
||||
@@ -26,6 +26,7 @@ package loki
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -281,11 +282,19 @@ func parseEngineBuild(out string) (build int, commit string) {
|
||||
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
|
||||
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
|
||||
// d'en installer un autre.
|
||||
//
|
||||
// Borné dans le temps comme binHelp : « loki serve » le lance avant le moteur
|
||||
// (points de reprise d'un hybride, voir ckptArgs), et un binaire qui reste
|
||||
// coincé à l'initialisation de ses backends ne doit pas bloquer le lancement —
|
||||
// passé le délai, build inconnu, donc ni avis ni drapeau.
|
||||
func engineBuildOf(bin string) (int, string) {
|
||||
if bin == "" || !isFile(bin) {
|
||||
return 0, ""
|
||||
}
|
||||
cmd := hideCmd(exec.Command(bin, "--version"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := hideCmd(exec.CommandContext(ctx, bin, "--version"))
|
||||
cmd.WaitDelay = 2 * time.Second // un descendant qui garde la sortie ouverte ne retient pas Wait
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
|
||||
return parseEngineBuild(string(out))
|
||||
|
||||
@@ -24,10 +24,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
|
||||
)
|
||||
|
||||
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
|
||||
@@ -46,6 +47,14 @@ func isExternalConfig(cfg map[string]string) bool {
|
||||
// externalActive : le preset actif est-il un endpoint externe ?
|
||||
func externalActive() bool { return isExternalConfig(ReadConfig()) }
|
||||
|
||||
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
|
||||
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
|
||||
// preset dit si le modèle accepte les images.
|
||||
func externalVisionActive() bool {
|
||||
cfg := ReadConfig()
|
||||
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
|
||||
}
|
||||
|
||||
// completionsURL normalise l'URL saisie en une URL de complétions complète.
|
||||
// Accepte les trois formes qu'on colle en pratique :
|
||||
//
|
||||
@@ -102,8 +111,9 @@ func (e chatEndpoint) auth(set func(k, v string)) {
|
||||
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
|
||||
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
|
||||
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
|
||||
// compaction, exactement comme pour un preset local.
|
||||
func externalPresetContent(url, model, key, ctx string) string {
|
||||
// compaction, exactement comme pour un preset local. vision = le modèle distant
|
||||
// accepte les images (EXTERNAL_VISION=1).
|
||||
func externalPresetContent(url, model, key, ctx string, vision bool) string {
|
||||
m := map[string]string{
|
||||
extKeyFlag: "1",
|
||||
extKeyURL: strings.TrimSpace(url),
|
||||
@@ -112,6 +122,9 @@ func externalPresetContent(url, model, key, ctx string) string {
|
||||
if k := strings.TrimSpace(key); k != "" {
|
||||
m[extKeyToken] = k
|
||||
}
|
||||
if vision {
|
||||
m[extKeyVision] = "1"
|
||||
}
|
||||
if c := strings.TrimSpace(ctx); c != "" {
|
||||
m["CTX"] = c
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -46,7 +47,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
|
||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep = resolveChatEndpoint()
|
||||
@@ -71,7 +72,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
|
||||
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
|
||||
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
|
||||
@@ -142,7 +143,7 @@ func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
|
||||
// d'effacement.
|
||||
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
|
||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -179,7 +180,7 @@ func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||
// Un preset externe demande explicitement d'écraser la clé par une vide.
|
||||
func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
|
||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -194,3 +195,51 @@ func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
|
||||
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
|
||||
// échouait à chaque fois.
|
||||
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if _, ok := body["chat_template_kwargs"]; ok {
|
||||
w.WriteHeader(400)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
|
||||
t.Fatalf("compactage refusé par l'API distante : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Vision et benchmark d'un preset externe : la case du preset décide, et le
|
||||
// bench (qui mesure le moteur local) refuse de tourner.
|
||||
func TestPresetExterneVisionEtBench(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !visionEnabled() || !engineSeesImages() {
|
||||
t.Error("preset externe déclaré multimodal : la vision devrait être active")
|
||||
}
|
||||
if _, err := runBench(context.Background(), benchOpts{Prompt: 10, Predict: 10}, nil); err == nil {
|
||||
t.Error("benchmark lancé sur un preset externe")
|
||||
}
|
||||
if got := chatModelName(); got != "gpt-4o" {
|
||||
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
|
||||
}
|
||||
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if visionEnabled() {
|
||||
t.Error("preset externe texte seul : la vision devrait être coupée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/bits"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Lecteur des métadonnées GGUF. Plusieurs réglages du moteur dépendent de ce que
|
||||
// le fichier contient VRAIMENT : un modèle qui embarque une tête MTP (prédiction
|
||||
// de plusieurs jetons, vérifiée exactement par le modèle cible), le nombre de
|
||||
// têtes KV par couche pour estimer le cache, une architecture hybride à état
|
||||
// récurrent. Jusqu'ici Loki ne le savait qu'à travers le nom du fichier, ce qui
|
||||
// ne prouve rien.
|
||||
//
|
||||
// On ne lit que l'en-tête, la section clé/valeur et la table des tenseurs : les
|
||||
// poids eux-mêmes ne sont jamais touchés, quelques Mo au plus pour un modèle de
|
||||
// 30 Go. Le fichier peut être en cours de téléchargement, tronqué, ou tout
|
||||
// simplement pas un GGUF : chaque longueur lue est bornée par ce qui reste du
|
||||
// fichier, et la moindre incohérence donne une erreur, jamais un plantage ni une
|
||||
// allocation géante. Une erreur veut dire « on ne sait pas » : l'appelant reste
|
||||
// sur le comportement prudent.
|
||||
|
||||
// GGUFInfo résume ce que Loki retient d'un modèle GGUF. Les champs absents du
|
||||
// fichier restent à zéro.
|
||||
type GGUFInfo struct {
|
||||
Version int // version du format (1 à 3)
|
||||
Arch string // general.architecture
|
||||
BlockCount int // <arch>.block_count
|
||||
NextN int // <arch>.nextn_predict_layers (couches MTP annoncées)
|
||||
ContextLength int // <arch>.context_length (contexte d'entraînement)
|
||||
ExpertCount int // <arch>.expert_count (0 = modèle dense)
|
||||
KeyLen int // <arch>.attention.key_length
|
||||
ValLen int // <arch>.attention.value_length
|
||||
// HeadCountKV : valeur unique, ou maximum sur les couches quand le fichier
|
||||
// donne un tableau par couche (HeadCountKVLayers, nil sinon). Sur un hybride,
|
||||
// les couches récurrentes y valent 0.
|
||||
HeadCountKV int
|
||||
HeadCountKVLayers []int
|
||||
FullAttnInterval int // <arch>.full_attention_interval, si présent
|
||||
Hybrid bool // au moins une clé <arch>.ssm.* : couches à état récurrent
|
||||
// Repli de llama.cpp quand key_length / value_length manquent : n_embd / n_head.
|
||||
EmbeddingLength int // <arch>.embedding_length
|
||||
HeadCount int // <arch>.attention.head_count (valeur unique seulement)
|
||||
// Taille de l'état récurrent par couche (llama_hparams::n_embd_r / n_embd_s),
|
||||
// pour estimer ce que pèse un état sauvegardé d'un modèle hybride.
|
||||
SSMConv int // <arch>.ssm.conv_kernel
|
||||
SSMInner int // <arch>.ssm.inner_size
|
||||
SSMState int // <arch>.ssm.state_size
|
||||
SSMGroups int // <arch>.ssm.group_count
|
||||
// HasNextNTensor : le tenseur blk.{BlockCount-1}.nextn.eh_proj.weight existe
|
||||
// dans l'une des tranches. C'est le test de llama.cpp lui-même pour reconnaître
|
||||
// une tête MTP : la clé nextn_predict_layers seule ne suffit pas, certains GGUF
|
||||
// la gardent alors que les tenseurs ont été retirés (tête publiée à part).
|
||||
HasNextNTensor bool
|
||||
TensorCount int // total sur toutes les tranches
|
||||
}
|
||||
|
||||
// Bornes de lecture. Elles sont larges devant les vrais modèles (une poignée de
|
||||
// milliers de tenseurs, une cinquantaine de clés hors vocabulaire) et servent
|
||||
// seulement à refuser un fichier aberrant avant d'allouer quoi que ce soit.
|
||||
const (
|
||||
ggufMaxString = 64 << 20 // modèle de chat le plus long connu : quelques dizaines de Ko
|
||||
ggufMaxKeyLen = 64 << 10
|
||||
ggufMaxTensorName = 4096 // GGML_MAX_NAME vaut 64
|
||||
ggufMaxKV = 1 << 20
|
||||
ggufMaxTensors = 1 << 22
|
||||
ggufMaxDims = 8 // GGML_MAX_DIMS vaut 4
|
||||
ggufMaxArrayDepth = 4
|
||||
ggufMaxLayerArray = 1 << 16
|
||||
)
|
||||
|
||||
// Types de valeur GGUF (gguf.h).
|
||||
const (
|
||||
ggufUint8 uint32 = iota
|
||||
ggufInt8
|
||||
ggufUint16
|
||||
ggufInt16
|
||||
ggufUint32
|
||||
ggufInt32
|
||||
ggufFloat32
|
||||
ggufBool
|
||||
ggufString
|
||||
ggufArray
|
||||
ggufUint64
|
||||
ggufInt64
|
||||
ggufFloat64
|
||||
)
|
||||
|
||||
var errGGUFTruncated = errors.New("GGUF tronqué")
|
||||
|
||||
// ggufScalarSize : taille en octets d'un type de taille fixe, 0 sinon.
|
||||
func ggufScalarSize(t uint32) int64 {
|
||||
switch t {
|
||||
case ggufUint8, ggufInt8, ggufBool:
|
||||
return 1
|
||||
case ggufUint16, ggufInt16:
|
||||
return 2
|
||||
case ggufUint32, ggufInt32, ggufFloat32:
|
||||
return 4
|
||||
case ggufUint64, ggufInt64, ggufFloat64:
|
||||
return 8
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ggufReader lit séquentiellement un GGUF en comptant ce qui reste du fichier :
|
||||
// toute longueur annoncée au-delà est une incohérence, détectée AVANT de lire.
|
||||
type ggufReader struct {
|
||||
r *bufio.Reader
|
||||
bo binary.ByteOrder
|
||||
v1 bool // GGUF v1 : compteurs et longueurs sur 32 bits
|
||||
size int64 // taille totale du fichier
|
||||
left int64 // octets restants dans le fichier
|
||||
buf [8]byte
|
||||
}
|
||||
|
||||
func (g *ggufReader) fixed(n int) ([]byte, error) {
|
||||
if int64(n) > g.left {
|
||||
return nil, errGGUFTruncated
|
||||
}
|
||||
b := g.buf[:n]
|
||||
if _, err := io.ReadFull(g.r, b); err != nil {
|
||||
return nil, errGGUFTruncated
|
||||
}
|
||||
g.left -= int64(n)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (g *ggufReader) u32() (uint32, error) {
|
||||
b, err := g.fixed(4)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return g.bo.Uint32(b), nil
|
||||
}
|
||||
|
||||
func (g *ggufReader) u64() (uint64, error) {
|
||||
b, err := g.fixed(8)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return g.bo.Uint64(b), nil
|
||||
}
|
||||
|
||||
// count lit un compteur ou une longueur : 32 bits en v1, 64 bits ensuite.
|
||||
func (g *ggufReader) count() (uint64, error) {
|
||||
if g.v1 {
|
||||
v, err := g.u32()
|
||||
return uint64(v), err
|
||||
}
|
||||
return g.u64()
|
||||
}
|
||||
|
||||
// countSize : taille sur disque d'un compteur, pour borner les tableaux de chaînes.
|
||||
func (g *ggufReader) countSize() int64 {
|
||||
if g.v1 {
|
||||
return 4
|
||||
}
|
||||
return 8
|
||||
}
|
||||
|
||||
func (g *ggufReader) skip(n int64) error {
|
||||
if n < 0 || n > g.left {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
for rest := n; rest > 0; {
|
||||
step := min(rest, 1<<30)
|
||||
if _, err := g.r.Discard(int(step)); err != nil {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
rest -= step
|
||||
}
|
||||
g.left -= n
|
||||
return nil
|
||||
}
|
||||
|
||||
// str lit une chaîne d'au plus max octets.
|
||||
func (g *ggufReader) str(max uint64) (string, error) {
|
||||
n, err := g.count()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n > max {
|
||||
return "", fmt.Errorf("chaîne GGUF de %d octets", n)
|
||||
}
|
||||
if int64(n) > g.left {
|
||||
return "", errGGUFTruncated
|
||||
}
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(g.r, b); err != nil {
|
||||
return "", errGGUFTruncated
|
||||
}
|
||||
g.left -= int64(n)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (g *ggufReader) skipStr() error {
|
||||
n, err := g.count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > ggufMaxString {
|
||||
return fmt.Errorf("chaîne GGUF de %d octets", n)
|
||||
}
|
||||
return g.skip(int64(n))
|
||||
}
|
||||
|
||||
// intVal lit une valeur entière (ou booléenne) de type t. ok vaut false pour les
|
||||
// flottants, lus et ignorés : aucune des clés retenues n'en est un.
|
||||
func (g *ggufReader) intVal(t uint32) (v int64, ok bool, err error) {
|
||||
size := ggufScalarSize(t)
|
||||
if size == 0 {
|
||||
return 0, false, fmt.Errorf("type GGUF %d inattendu", t)
|
||||
}
|
||||
b, err := g.fixed(int(size))
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
switch t {
|
||||
case ggufUint8, ggufBool:
|
||||
return int64(b[0]), true, nil
|
||||
case ggufInt8:
|
||||
return int64(int8(b[0])), true, nil
|
||||
case ggufUint16:
|
||||
return int64(g.bo.Uint16(b)), true, nil
|
||||
case ggufInt16:
|
||||
return int64(int16(g.bo.Uint16(b))), true, nil
|
||||
case ggufUint32:
|
||||
return int64(g.bo.Uint32(b)), true, nil
|
||||
case ggufInt32:
|
||||
return int64(int32(g.bo.Uint32(b))), true, nil
|
||||
case ggufUint64:
|
||||
u := g.bo.Uint64(b)
|
||||
if u > math.MaxInt64 {
|
||||
u = math.MaxInt64
|
||||
}
|
||||
return int64(u), true, nil
|
||||
case ggufInt64:
|
||||
return int64(g.bo.Uint64(b)), true, nil
|
||||
}
|
||||
return 0, false, nil // flottant
|
||||
}
|
||||
|
||||
// skipArray saute n éléments de type et, tableaux imbriqués compris (profondeur
|
||||
// bornée). Le vocabulaire (150 000 chaînes) passe par ici sans rien allouer.
|
||||
func (g *ggufReader) skipArray(et uint32, n uint64, depth int) error {
|
||||
if depth > ggufMaxArrayDepth {
|
||||
return errors.New("tableaux GGUF trop imbriqués")
|
||||
}
|
||||
if size := ggufScalarSize(et); size > 0 {
|
||||
if n > uint64(g.left/size) {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
return g.skip(int64(n) * size)
|
||||
}
|
||||
switch et {
|
||||
case ggufString:
|
||||
if n > uint64(g.left/g.countSize()) {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
for i := uint64(0); i < n; i++ {
|
||||
if err := g.skipStr(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case ggufArray:
|
||||
// Chaque sous-tableau coûte au moins son type et son compteur.
|
||||
if n > uint64(g.left/(4+g.countSize())) {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
for i := uint64(0); i < n; i++ {
|
||||
sub, err := g.u32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sn, err := g.count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.skipArray(sub, sn, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("type GGUF %d inconnu", et)
|
||||
}
|
||||
|
||||
// ggufFile : ce qu'on retient d'UNE tranche avant de résoudre les clés selon
|
||||
// l'architecture (general.architecture n'est pas forcément la première clé).
|
||||
type ggufFile struct {
|
||||
version int
|
||||
arch string
|
||||
ints map[string]int64
|
||||
arrays map[string][]int64 // tableaux d'entiers retenus (têtes KV par couche)
|
||||
keys []string // noms de toutes les clés, pour repérer <arch>.ssm.*
|
||||
tensors int
|
||||
nextn []string // noms des tenseurs *.nextn.eh_proj.weight
|
||||
}
|
||||
|
||||
// keepIntArray : les seuls tableaux qu'on lit au lieu de les sauter.
|
||||
func keepIntArray(key string) bool {
|
||||
return strings.HasSuffix(key, ".attention.head_count_kv")
|
||||
}
|
||||
|
||||
// readGGUFHeader lit l'en-tête, les clés et la table des tenseurs d'un fichier.
|
||||
func readGGUFHeader(path string) (*ggufFile, error) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fh.Close()
|
||||
st, err := fh.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st.IsDir() {
|
||||
return nil, fmt.Errorf("%s est un dossier", baseName(path))
|
||||
}
|
||||
f, err := parseGGUF(fh, st.Size())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s : %w", baseName(path), err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// parseGGUF lit size octets au plus de r. Un panic éventuel (fichier
|
||||
// pathologique non prévu par les bornes) est rattrapé en erreur : lire des
|
||||
// métadonnées ne doit jamais faire tomber Loki.
|
||||
func parseGGUF(r io.Reader, size int64) (f *ggufFile, err error) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
f, err = nil, fmt.Errorf("GGUF illisible : %v", p)
|
||||
}
|
||||
}()
|
||||
g := &ggufReader{r: bufio.NewReaderSize(r, 1<<20), bo: binary.LittleEndian, size: size, left: size}
|
||||
magic, err := g.fixed(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(magic) != "GGUF" {
|
||||
return nil, errors.New("pas un fichier GGUF")
|
||||
}
|
||||
ver, err := g.u32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Les GGUF gros-boutistes (s390x) gardent la même signature : seule la
|
||||
// version lue à l'envers trahit l'ordre des octets.
|
||||
if ver&0xFFFF == 0 {
|
||||
if sw := bits.ReverseBytes32(ver); sw >= 1 && sw <= 3 {
|
||||
g.bo, ver = binary.BigEndian, sw
|
||||
}
|
||||
}
|
||||
if ver < 1 || ver > 3 {
|
||||
return nil, fmt.Errorf("version GGUF %d non prise en charge", ver)
|
||||
}
|
||||
g.v1 = ver == 1
|
||||
nTensors, err := g.count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nKV, err := g.count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nKV > ggufMaxKV || nTensors > ggufMaxTensors {
|
||||
return nil, fmt.Errorf("compteurs GGUF aberrants (%d clés, %d tenseurs)", nKV, nTensors)
|
||||
}
|
||||
f = &ggufFile{version: int(ver), ints: map[string]int64{}, arrays: map[string][]int64{}, tensors: int(nTensors)}
|
||||
|
||||
for i := uint64(0); i < nKV; i++ {
|
||||
key, err := g.str(ggufMaxKeyLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := g.u32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.keys = append(f.keys, key)
|
||||
switch t {
|
||||
case ggufString:
|
||||
if key == "general.architecture" {
|
||||
if f.arch, err = g.str(ggufMaxKeyLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := g.skipStr(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ggufArray:
|
||||
et, err := g.u32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := g.count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if keepIntArray(key) && et != ggufFloat32 && et != ggufFloat64 && ggufScalarSize(et) > 0 && n <= ggufMaxLayerArray {
|
||||
vals := make([]int64, 0, min(n, uint64(g.left)))
|
||||
for j := uint64(0); j < n; j++ {
|
||||
v, _, err := g.intVal(et)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vals = append(vals, v)
|
||||
}
|
||||
f.arrays[key] = vals
|
||||
} else if err := g.skipArray(et, n, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
v, ok, err := g.intVal(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
f.ints[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dimSize := int64(8)
|
||||
if g.v1 {
|
||||
dimSize = 4
|
||||
}
|
||||
var maxOff uint64
|
||||
for i := uint64(0); i < nTensors; i++ {
|
||||
name, err := g.str(ggufMaxTensorName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nd, err := g.u32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nd > ggufMaxDims {
|
||||
return nil, fmt.Errorf("tenseur %q à %d dimensions", name, nd)
|
||||
}
|
||||
// dimensions et type (u32), puis position des données (u64)
|
||||
if err := g.skip(int64(nd)*dimSize + 4); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
off, err := g.u64()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxOff = max(maxOff, off)
|
||||
if strings.HasSuffix(name, ".nextn.eh_proj.weight") {
|
||||
f.nextn = append(f.nextn, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Les données commencent au prochain multiple de general.alignment. Le début
|
||||
// du dernier tenseur doit tenir dans le fichier : un GGUF coupé dans ses poids
|
||||
// (copie interrompue, disque plein) a un en-tête intact, il faut le voir
|
||||
// quand même. On ne connaît pas la taille de chaque tenseur sans la table des
|
||||
// types ggml : vérifier son premier octet suffit à attraper l'essentiel.
|
||||
align := int64(32)
|
||||
if a, ok := f.ints["general.alignment"]; ok {
|
||||
if a <= 0 || a > 1<<20 || a&(a-1) != 0 {
|
||||
return nil, fmt.Errorf("alignement GGUF %d invalide", a)
|
||||
}
|
||||
align = a
|
||||
}
|
||||
if nTensors > 0 {
|
||||
pos := g.size - g.left
|
||||
dataStart := (pos + align - 1) / align * align
|
||||
if maxOff >= uint64(g.size) || dataStart+int64(maxOff) >= g.size {
|
||||
return nil, errGGUFTruncated
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// clampInt ramène une valeur de métadonnée dans [0, MaxInt32] : un champ négatif
|
||||
// ou démesuré n'a pas de sens pour un compte de couches ou de têtes.
|
||||
func clampInt(v int64) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > math.MaxInt32 {
|
||||
return math.MaxInt32
|
||||
}
|
||||
return int(v)
|
||||
}
|
||||
|
||||
// ggufInfoFrom résout les clés de la première tranche selon l'architecture et
|
||||
// cherche la tête MTP dans les tenseurs de toutes les tranches.
|
||||
func ggufInfoFrom(first *ggufFile, all []*ggufFile) GGUFInfo {
|
||||
in := GGUFInfo{Version: first.version, Arch: first.arch}
|
||||
p := first.arch + "."
|
||||
get := func(k string) int { return clampInt(first.ints[p+k]) }
|
||||
in.BlockCount = get("block_count")
|
||||
in.NextN = get("nextn_predict_layers")
|
||||
in.ContextLength = get("context_length")
|
||||
in.ExpertCount = get("expert_count")
|
||||
in.KeyLen = get("attention.key_length")
|
||||
in.ValLen = get("attention.value_length")
|
||||
in.FullAttnInterval = get("full_attention_interval")
|
||||
in.EmbeddingLength = get("embedding_length")
|
||||
in.HeadCount = get("attention.head_count")
|
||||
in.SSMConv = get("ssm.conv_kernel")
|
||||
in.SSMInner = get("ssm.inner_size")
|
||||
in.SSMState = get("ssm.state_size")
|
||||
in.SSMGroups = get("ssm.group_count")
|
||||
in.HeadCountKV = get("attention.head_count_kv")
|
||||
if arr, ok := first.arrays[p+"attention.head_count_kv"]; ok {
|
||||
in.HeadCountKVLayers = make([]int, len(arr))
|
||||
in.HeadCountKV = 0
|
||||
for i, v := range arr {
|
||||
in.HeadCountKVLayers[i] = clampInt(v)
|
||||
in.HeadCountKV = max(in.HeadCountKV, in.HeadCountKVLayers[i])
|
||||
}
|
||||
}
|
||||
if first.arch != "" {
|
||||
for _, k := range first.keys {
|
||||
if strings.HasPrefix(k, p+"ssm.") {
|
||||
in.Hybrid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
want := ""
|
||||
if in.BlockCount > 0 {
|
||||
want = "blk." + strconv.Itoa(in.BlockCount-1) + ".nextn.eh_proj.weight"
|
||||
}
|
||||
for _, f := range all {
|
||||
in.TensorCount += f.tensors
|
||||
for _, n := range f.nextn {
|
||||
if n == want {
|
||||
in.HasNextNTensor = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// Cache par chemin : la même liste de modèles est consultée à chaque démarrage
|
||||
// et à chaque affichage. L'empreinte (taille et date de chaque tranche) suffit à
|
||||
// voir un fichier remplacé ou un téléchargement terminé ; les erreurs ne sont
|
||||
// pas gardées, un fichier en cours de téléchargement sera relu une fois complet.
|
||||
type ggufCacheEntry struct {
|
||||
stamp string
|
||||
info GGUFInfo
|
||||
}
|
||||
|
||||
var (
|
||||
ggufCacheMu sync.Mutex
|
||||
ggufCache = map[string]ggufCacheEntry{}
|
||||
)
|
||||
|
||||
const ggufCacheMax = 256
|
||||
|
||||
// ggufMeta lit les métadonnées du modèle désigné par path. Pour un modèle en
|
||||
// tranches, path peut désigner n'importe laquelle : les clés viennent de la
|
||||
// première (les suivantes ne portent que split.*), les tenseurs de toutes. Une
|
||||
// tranche manquante est une erreur : le modèle ne démarrerait pas, et on ne
|
||||
// saurait pas dire si la tête MTP s'y trouvait.
|
||||
func ggufMeta(path string) (GGUFInfo, error) {
|
||||
path = filepath.Clean(path)
|
||||
dir := filepath.Dir(path)
|
||||
fam := shardFamily(path)
|
||||
paths := make([]string, len(fam))
|
||||
var stamp strings.Builder
|
||||
for i, n := range fam {
|
||||
paths[i] = filepath.Join(dir, n)
|
||||
st, err := os.Stat(paths[i])
|
||||
if err != nil {
|
||||
return GGUFInfo{}, err
|
||||
}
|
||||
if st.IsDir() {
|
||||
return GGUFInfo{}, fmt.Errorf("%s est un dossier", n)
|
||||
}
|
||||
fmt.Fprintf(&stamp, "%d:%d;", st.Size(), st.ModTime().UnixNano())
|
||||
}
|
||||
key := paths[0]
|
||||
|
||||
ggufCacheMu.Lock()
|
||||
e, ok := ggufCache[key]
|
||||
ggufCacheMu.Unlock()
|
||||
if ok && e.stamp == stamp.String() {
|
||||
return cloneGGUFInfo(e.info), nil
|
||||
}
|
||||
|
||||
files := make([]*ggufFile, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
f, err := readGGUFHeader(p)
|
||||
if err != nil {
|
||||
return GGUFInfo{}, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
info := ggufInfoFrom(files[0], files)
|
||||
|
||||
ggufCacheMu.Lock()
|
||||
if len(ggufCache) >= ggufCacheMax {
|
||||
ggufCache = map[string]ggufCacheEntry{}
|
||||
}
|
||||
ggufCache[key] = ggufCacheEntry{stamp: stamp.String(), info: info}
|
||||
ggufCacheMu.Unlock()
|
||||
return cloneGGUFInfo(info), nil
|
||||
}
|
||||
|
||||
// cloneGGUFInfo : l'appelant peut modifier le tableau par couche sans toucher au
|
||||
// cache.
|
||||
func cloneGGUFInfo(in GGUFInfo) GGUFInfo {
|
||||
if in.HeadCountKVLayers != nil {
|
||||
in.HeadCountKVLayers = append([]int(nil), in.HeadCountKVLayers...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ggufBuilder écrit un GGUF synthétique : en-tête, clés typées, table des
|
||||
// tenseurs, sans aucune donnée de poids (le lecteur n'en lit jamais).
|
||||
type ggufBuilder struct {
|
||||
bo binary.ByteOrder
|
||||
version uint32
|
||||
nKV int
|
||||
nT int
|
||||
kv bytes.Buffer
|
||||
tensors bytes.Buffer
|
||||
}
|
||||
|
||||
func newGGUF() *ggufBuilder { return &ggufBuilder{bo: binary.LittleEndian, version: 3} }
|
||||
|
||||
func (b *ggufBuilder) cnt(w *bytes.Buffer, n uint64) {
|
||||
if b.version == 1 {
|
||||
binary.Write(w, b.bo, uint32(n))
|
||||
} else {
|
||||
binary.Write(w, b.bo, n)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) str(w *bytes.Buffer, s string) {
|
||||
b.cnt(w, uint64(len(s)))
|
||||
w.WriteString(s)
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) key(k string, t uint32) {
|
||||
b.nKV++
|
||||
b.str(&b.kv, k)
|
||||
binary.Write(&b.kv, b.bo, t)
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) u32(k string, v uint32) *ggufBuilder {
|
||||
b.key(k, ggufUint32)
|
||||
binary.Write(&b.kv, b.bo, v)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) i64(k string, v int64) *ggufBuilder {
|
||||
b.key(k, ggufInt64)
|
||||
binary.Write(&b.kv, b.bo, v)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) f32(k string, v float32) *ggufBuilder {
|
||||
b.key(k, ggufFloat32)
|
||||
binary.Write(&b.kv, b.bo, math.Float32bits(v))
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) boolean(k string, v bool) *ggufBuilder {
|
||||
b.key(k, ggufBool)
|
||||
if v {
|
||||
b.kv.WriteByte(1)
|
||||
} else {
|
||||
b.kv.WriteByte(0)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) s(k, v string) *ggufBuilder {
|
||||
b.key(k, ggufString)
|
||||
b.str(&b.kv, v)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) strArr(k string, vs []string) *ggufBuilder {
|
||||
b.key(k, ggufArray)
|
||||
binary.Write(&b.kv, b.bo, ggufString)
|
||||
b.cnt(&b.kv, uint64(len(vs)))
|
||||
for _, v := range vs {
|
||||
b.str(&b.kv, v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) i32Arr(k string, vs []int32) *ggufBuilder {
|
||||
b.key(k, ggufArray)
|
||||
binary.Write(&b.kv, b.bo, ggufInt32)
|
||||
b.cnt(&b.kv, uint64(len(vs)))
|
||||
for _, v := range vs {
|
||||
binary.Write(&b.kv, b.bo, v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// nested : tableau de deux tableaux de u8, pour vérifier le saut récursif.
|
||||
func (b *ggufBuilder) nested(k string) *ggufBuilder {
|
||||
b.key(k, ggufArray)
|
||||
binary.Write(&b.kv, b.bo, ggufArray)
|
||||
b.cnt(&b.kv, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
binary.Write(&b.kv, b.bo, ggufUint8)
|
||||
b.cnt(&b.kv, 3)
|
||||
b.kv.Write([]byte{1, 2, 3})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) tensor(name string, dims ...uint64) *ggufBuilder {
|
||||
b.str(&b.tensors, name)
|
||||
binary.Write(&b.tensors, b.bo, uint32(len(dims)))
|
||||
for _, d := range dims {
|
||||
if b.version == 1 {
|
||||
binary.Write(&b.tensors, b.bo, uint32(d))
|
||||
} else {
|
||||
binary.Write(&b.tensors, b.bo, d)
|
||||
}
|
||||
}
|
||||
binary.Write(&b.tensors, b.bo, uint32(0)) // type F32
|
||||
binary.Write(&b.tensors, b.bo, uint64(b.nT*32)) // position des données
|
||||
b.nT++
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) bytes() []byte {
|
||||
var out bytes.Buffer
|
||||
out.WriteString("GGUF")
|
||||
binary.Write(&out, b.bo, b.version)
|
||||
b.cnt(&out, uint64(b.nT))
|
||||
b.cnt(&out, uint64(b.nKV))
|
||||
out.Write(b.kv.Bytes())
|
||||
out.Write(b.tensors.Bytes())
|
||||
// bourrage jusqu'à l'alignement par défaut, puis 32 octets de « poids » par
|
||||
// tenseur
|
||||
if b.nT > 0 {
|
||||
out.Write(make([]byte, (32-out.Len()%32)%32+32*b.nT))
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func writeGGUF(t *testing.T, dir, name string, data []byte) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// qwenMTP : modèle hybride à 4 couches, têtes KV par couche (0 sur les couches
|
||||
// récurrentes), tête MTP sur la dernière couche, vocabulaire à sauter.
|
||||
func qwenMTP(b *ggufBuilder) *ggufBuilder {
|
||||
return b.u32("general.alignment", 32).
|
||||
strArr("tokenizer.ggml.tokens", []string{"<s>", "a", "bb", strings.Repeat("c", 300)}).
|
||||
s("general.architecture", "qwen35").
|
||||
s("general.name", "Qwen synthétique").
|
||||
u32("qwen35.block_count", 4).
|
||||
u32("qwen35.nextn_predict_layers", 1).
|
||||
i64("qwen35.context_length", 262144).
|
||||
u32("qwen35.attention.key_length", 256).
|
||||
u32("qwen35.attention.value_length", 256).
|
||||
i32Arr("qwen35.attention.head_count_kv", []int32{0, 0, 0, 4}).
|
||||
u32("qwen35.full_attention_interval", 4).
|
||||
u32("qwen35.ssm.conv_kernel", 4).
|
||||
f32("qwen35.rope.freq_base", 1e7).
|
||||
boolean("tokenizer.ggml.add_bos_token", false).
|
||||
nested("bidon.imbrique").
|
||||
tensor("token_embd.weight", 1024, 32000).
|
||||
tensor("blk.0.attn_norm.weight", 1024).
|
||||
tensor("blk.3.nextn.eh_proj.weight", 2048, 1024)
|
||||
}
|
||||
|
||||
func TestGGUFMetaQwenHybrideMTP(t *testing.T) {
|
||||
p := writeGGUF(t, t.TempDir(), "qwen.gguf", qwenMTP(newGGUF()).bytes())
|
||||
got, err := ggufMeta(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := GGUFInfo{
|
||||
Version: 3, Arch: "qwen35", BlockCount: 4, NextN: 1, ContextLength: 262144,
|
||||
KeyLen: 256, ValLen: 256, HeadCountKV: 4, HeadCountKVLayers: []int{0, 0, 0, 4},
|
||||
FullAttnInterval: 4, Hybrid: true, SSMConv: 4, HasNextNTensor: true, TensorCount: 3,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ggufMeta =\n%+v\nattendu\n%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// La clé nextn_predict_layers ne prouve rien : seul le tenseur de la DERNIÈRE
|
||||
// couche compte, comme dans llama.cpp.
|
||||
func TestGGUFMetaTeteMTPAbsenteOuMalPlacee(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := func() *ggufBuilder {
|
||||
return newGGUF().s("general.architecture", "glm4moe").
|
||||
u32("glm4moe.block_count", 47).
|
||||
u32("glm4moe.nextn_predict_layers", 1).
|
||||
u32("glm4moe.expert_count", 128).
|
||||
u32("glm4moe.attention.head_count_kv", 8)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
b *ggufBuilder
|
||||
}{
|
||||
{"sans-tenseur.gguf", base().tensor("blk.45.ffn_up.weight", 8, 8)},
|
||||
{"mauvaise-couche.gguf", base().tensor("blk.45.nextn.eh_proj.weight", 8, 8)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ggufMeta(writeGGUF(t, dir, c.name, c.b.bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("%s : %v", c.name, err)
|
||||
}
|
||||
if got.HasNextNTensor || got.NextN != 1 || got.ExpertCount != 128 || got.HeadCountKV != 8 ||
|
||||
got.HeadCountKVLayers != nil || got.Hybrid {
|
||||
t.Fatalf("%s : %+v", c.name, got)
|
||||
}
|
||||
}
|
||||
got, err := ggufMeta(writeGGUF(t, dir, "ok.gguf", base().tensor("blk.46.nextn.eh_proj.weight", 8, 8).bytes()))
|
||||
if err != nil || !got.HasNextNTensor {
|
||||
t.Fatalf("tête MTP en dernière couche non vue : %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Modèle en tranches : les clés sont dans la première, la tête MTP dans la
|
||||
// dernière. N'importe quelle tranche peut être désignée.
|
||||
func TestGGUFMetaTranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := newGGUF().s("general.architecture", "qwen35").u32("qwen35.block_count", 4).
|
||||
u32("split.count", 2).tensor("token_embd.weight", 8, 8)
|
||||
second := newGGUF().u32("split.no", 1).u32("split.count", 2).
|
||||
tensor("blk.3.nextn.eh_proj.weight", 8, 8).tensor("output.weight", 8, 8)
|
||||
p1 := writeGGUF(t, dir, "m-00001-of-00002.gguf", first.bytes())
|
||||
writeGGUF(t, dir, "m-00002-of-00002.gguf", second.bytes())
|
||||
for _, p := range []string{p1, filepath.Join(dir, "m-00002-of-00002.gguf")} {
|
||||
got, err := ggufMeta(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Arch != "qwen35" || !got.HasNextNTensor || got.TensorCount != 3 {
|
||||
t.Fatalf("%s : %+v", baseName(p), got)
|
||||
}
|
||||
}
|
||||
// tranche manquante : on ne sait pas, donc erreur
|
||||
writeGGUF(t, dir, "n-00001-of-00002.gguf", first.bytes())
|
||||
if _, err := ggufMeta(filepath.Join(dir, "n-00001-of-00002.gguf")); err == nil {
|
||||
t.Fatal("tranche manquante acceptée")
|
||||
}
|
||||
// gguf-split --no-tensor-first-split : la première tranche ne porte que les
|
||||
// clés, aucun tenseur ni aucune donnée. Elle doit passer telle quelle.
|
||||
meta := newGGUF().s("general.architecture", "glm4moe").u32("glm4moe.block_count", 47).
|
||||
u32("split.count", 2)
|
||||
rest := newGGUF().u32("split.no", 1).tensor("blk.46.nextn.eh_proj.weight", 8, 8)
|
||||
writeGGUF(t, dir, "s-00001-of-00002.gguf", meta.bytes())
|
||||
writeGGUF(t, dir, "s-00002-of-00002.gguf", rest.bytes())
|
||||
got, err := ggufMeta(filepath.Join(dir, "s-00001-of-00002.gguf"))
|
||||
if err != nil || got.Arch != "glm4moe" || !got.HasNextNTensor || got.TensorCount != 1 {
|
||||
t.Fatalf("première tranche sans tenseur : %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// v1 (compteurs 32 bits) et gros-boutiste : même résultat que le v3 ordinaire.
|
||||
func TestGGUFMetaVersionsEtOrdre(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v1 := newGGUF()
|
||||
v1.version = 1
|
||||
v2 := newGGUF()
|
||||
v2.version = 2
|
||||
be := newGGUF()
|
||||
be.bo = binary.BigEndian
|
||||
for name, b := range map[string]*ggufBuilder{"v1.gguf": v1, "v2.gguf": v2, "be.gguf": be} {
|
||||
got, err := ggufMeta(writeGGUF(t, dir, name, qwenMTP(b).bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("%s : %v", name, err)
|
||||
}
|
||||
if got.Arch != "qwen35" || got.BlockCount != 4 || !got.HasNextNTensor ||
|
||||
!reflect.DeepEqual(got.HeadCountKVLayers, []int{0, 0, 0, 4}) || got.Version != int(b.version) {
|
||||
t.Fatalf("%s : %+v", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fichier en cours de téléchargement : chaque coupure donne une erreur, jamais
|
||||
// un panic ni un faux résultat.
|
||||
func TestGGUFMetaTronque(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
full := qwenMTP(newGGUF()).bytes()
|
||||
// Toutes les coupures jusqu'au premier octet du dernier tenseur (le seul que
|
||||
// le lecteur sait situer sans la table des types ggml).
|
||||
for cut := 0; cut <= len(full)-32; cut++ {
|
||||
if _, err := parseGGUF(bytes.NewReader(full[:cut]), int64(cut)); err == nil {
|
||||
t.Fatalf("coupure à %d/%d acceptée", cut, len(full))
|
||||
}
|
||||
}
|
||||
if _, err := parseGGUF(bytes.NewReader(full), int64(len(full))); err != nil {
|
||||
t.Fatalf("fichier complet refusé : %v", err)
|
||||
}
|
||||
// et par le chemin fichier, pour une coupure dans l'en-tête
|
||||
if _, err := ggufMeta(writeGGUF(t, dir, "cut.gguf", full[:len(full)/3])); err == nil {
|
||||
t.Fatal("fichier coupé accepté")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGGUFMetaFichiersAberrants(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
hdr := func(ver uint32, nT, nKV uint64) *bytes.Buffer {
|
||||
var w bytes.Buffer
|
||||
w.WriteString("GGUF")
|
||||
binary.Write(&w, binary.LittleEndian, ver)
|
||||
binary.Write(&w, binary.LittleEndian, nT)
|
||||
binary.Write(&w, binary.LittleEndian, nKV)
|
||||
return &w
|
||||
}
|
||||
// clé annoncée à 1 Eo
|
||||
hugeKey := hdr(3, 0, 1)
|
||||
binary.Write(hugeKey, binary.LittleEndian, uint64(1)<<60)
|
||||
// tableau de chaînes annoncé à 2^62 éléments
|
||||
hugeArr := hdr(3, 0, 1)
|
||||
binary.Write(hugeArr, binary.LittleEndian, uint64(1))
|
||||
hugeArr.WriteString("k")
|
||||
binary.Write(hugeArr, binary.LittleEndian, ggufArray)
|
||||
binary.Write(hugeArr, binary.LittleEndian, ggufString)
|
||||
binary.Write(hugeArr, binary.LittleEndian, uint64(1)<<62)
|
||||
// type de valeur inconnu
|
||||
badType := hdr(3, 0, 1)
|
||||
binary.Write(badType, binary.LittleEndian, uint64(1))
|
||||
badType.WriteString("k")
|
||||
binary.Write(badType, binary.LittleEndian, uint32(99))
|
||||
binary.Write(badType, binary.LittleEndian, uint64(0))
|
||||
// tenseur à 1000 dimensions
|
||||
badDims := hdr(3, 1, 0)
|
||||
binary.Write(badDims, binary.LittleEndian, uint64(1))
|
||||
badDims.WriteString("t")
|
||||
binary.Write(badDims, binary.LittleEndian, uint32(1000))
|
||||
badDims.Write(make([]byte, 64))
|
||||
|
||||
cases := map[string][]byte{
|
||||
"vide": nil,
|
||||
"alignement": newGGUF().u32("general.alignment", 24).tensor("t", 1).bytes(),
|
||||
"signature": []byte("GGML\x03\x00\x00\x00" + strings.Repeat("\x00", 16)),
|
||||
"version": hdr(4, 0, 0).Bytes(),
|
||||
"compteurs": hdr(3, 1<<40, 1<<40).Bytes(),
|
||||
"cle-geante": hugeKey.Bytes(),
|
||||
"tableau": hugeArr.Bytes(),
|
||||
"type-inconnu": badType.Bytes(),
|
||||
"dimensions": badDims.Bytes(),
|
||||
}
|
||||
for name, data := range cases {
|
||||
if _, err := ggufMeta(writeGGUF(t, dir, name+".gguf", data)); err == nil {
|
||||
t.Errorf("%s : accepté", name)
|
||||
}
|
||||
}
|
||||
if _, err := ggufMeta(filepath.Join(dir, "absent.gguf")); err == nil {
|
||||
t.Error("fichier absent accepté")
|
||||
}
|
||||
}
|
||||
|
||||
// Octet corrompu n'importe où dans l'en-tête (v1 et v3) : le lecteur doit rendre
|
||||
// la main vite, sans panic remonté ni allocation démesurée. Les valeurs 0xFF et
|
||||
// 0x7F transforment un compteur en nombre géant, le cas qui ferait mal.
|
||||
func TestGGUFMetaOctetCorrompu(t *testing.T) {
|
||||
v1 := newGGUF()
|
||||
v1.version = 1
|
||||
for _, b := range []*ggufBuilder{newGGUF(), v1} {
|
||||
full := qwenMTP(b).bytes()
|
||||
hdr := len(full) - 3*32 // le bourrage et les « poids » ne sont jamais lus
|
||||
for i := 0; i < hdr; i++ {
|
||||
for _, v := range []byte{0xFF, 0x7F, 0x00} {
|
||||
mut := append([]byte(nil), full...)
|
||||
mut[i] = v
|
||||
// le filet de sécurité existe, mais aucune entrée ne doit y tomber
|
||||
if _, err := parseGGUF(bytes.NewReader(mut), int64(len(mut))); err != nil &&
|
||||
strings.Contains(err.Error(), "illisible") {
|
||||
t.Fatalf("v%d, octet %d = %#x : %v", b.version, i, v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le cache tient tant que taille et date sont les mêmes, et se renouvelle dès
|
||||
// qu'elles changent (téléchargement terminé, fichier remplacé).
|
||||
func TestGGUFMetaCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
data := qwenMTP(newGGUF()).bytes()
|
||||
p := writeGGUF(t, dir, "c.gguf", data)
|
||||
stamp := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
if err := os.Chtimes(p, stamp, stamp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := ggufMeta(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.HeadCountKVLayers[0] = 99 // ne doit pas atteindre le cache
|
||||
|
||||
// même taille, même date, contenu illisible : le cache répond
|
||||
writeGGUF(t, dir, "c.gguf", make([]byte, len(data)))
|
||||
if err := os.Chtimes(p, stamp, stamp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := ggufMeta(p)
|
||||
if err != nil || b.HeadCountKVLayers[0] != 0 || !b.HasNextNTensor {
|
||||
t.Fatalf("cache non utilisé ou altéré : %+v, %v", b, err)
|
||||
}
|
||||
|
||||
// date différente : relecture, donc erreur sur le contenu illisible
|
||||
if err := os.Chtimes(p, stamp.Add(time.Second), stamp.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ggufMeta(p); err == nil {
|
||||
t.Fatal("fichier modifié servi depuis le cache")
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,11 @@ func modelRefKey(v string) string {
|
||||
}
|
||||
|
||||
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
|
||||
// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets
|
||||
// d'avant la clé MMPROJ l'écrivaient là).
|
||||
// projecteur vision, le brouillon (MODEL_DRAFT : une tête MTP effacée sous le
|
||||
// moteur coupe la spéculation sans rien dire), et un --mmproj posé à la main
|
||||
// dans EXTRA_ARGS (les presets d'avant la clé MMPROJ l'écrivaient là).
|
||||
func modelKeysOf(env map[string]string) []string {
|
||||
vals := []string{env["MODEL"], env["MMPROJ"]}
|
||||
vals := []string{env["MODEL"], env["MMPROJ"], env["MODEL_DRAFT"]}
|
||||
args := splitArgs(env["EXTRA_ARGS"])
|
||||
for i, a := range args {
|
||||
if a == "--mmproj" && i+1 < len(args) {
|
||||
|
||||
@@ -142,6 +142,14 @@ func TestProjecteurCompteCommeReference(t *testing.T) {
|
||||
if len(users) != 1 || users[0] != "Vision" {
|
||||
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
|
||||
}
|
||||
// La tête MTP publiée à part aussi (MODEL_DRAFT).
|
||||
writeModel(t, modelsDir(), "mtp-Qwen3.6-27B.gguf", 4)
|
||||
if _, err := SavePreset("", "MTP", `MODEL="m.gguf"`+"\n"+`SPEC=auto`+"\n"+`MODEL_DRAFT="mtp-Qwen3.6-27B.gguf"`+"\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if users, _ := modelUsers("mtp-Qwen3.6-27B.gguf"); len(users) != 1 || users[0] != "MTP" {
|
||||
t.Fatalf("MODEL_DRAFT= non compté : %v", users)
|
||||
}
|
||||
}
|
||||
|
||||
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
|
||||
|
||||
@@ -189,7 +189,10 @@ func safePresetPath(name string) (string, error) {
|
||||
// version effaçait la clé, et le moteur repartait sur le défaut « toutes les
|
||||
// interfaces » : une machine volontairement fermée se rouvrait toute seule au
|
||||
// premier changement de preset.
|
||||
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"}
|
||||
// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la
|
||||
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas
|
||||
// disparaître à la bascule suivante.
|
||||
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES"}
|
||||
|
||||
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
|
||||
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
|
||||
@@ -199,7 +202,8 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
|
||||
// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
|
||||
// modèle se retrouvait entièrement sur une seule carte et mourait sur
|
||||
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
|
||||
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true}
|
||||
// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle.
|
||||
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true}
|
||||
|
||||
// applyPresetFile installe le preset comme configuration active, en réinjectant
|
||||
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
|
||||
@@ -432,6 +436,9 @@ func DeletePreset(id string) error {
|
||||
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
|
||||
// sous le même id.
|
||||
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
|
||||
// Même ménage pour son benchmark : un preset recréé sous cet id en
|
||||
// hériterait, alors qu'il a été mesuré sur un autre modèle.
|
||||
deletePresetBench(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Configuration courante : un preset + les réglages machine.
|
||||
setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n")
|
||||
setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\nCUDA_LAUNCH_QUEUES=off\n")
|
||||
target := filepath.Join(home, "cible.env")
|
||||
write(target, "MODEL=nouveau.gguf\nCTX=8192\n")
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
t.Fatalf("le preset n'a pas été appliqué : %v", cfg)
|
||||
}
|
||||
for k, want := range map[string]string{
|
||||
"CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always",
|
||||
"CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always", "CUDA_LAUNCH_QUEUES": "off",
|
||||
} {
|
||||
if cfg[k] != want {
|
||||
t.Errorf("%s = %q après bascule, attendu %q (réglage machine effacé)", k, cfg[k], want)
|
||||
|
||||
+427
-90
@@ -4,15 +4,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
|
||||
// helpSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
|
||||
//
|
||||
// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le
|
||||
// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne
|
||||
@@ -22,9 +24,11 @@ import (
|
||||
// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement
|
||||
// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide
|
||||
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
|
||||
// tort qu'il ne gère pas le drapeau.
|
||||
func binSupportsReasoningFlag(bin string) bool {
|
||||
return strings.Contains(binHelp(bin), "--reasoning ")
|
||||
// tort qu'il ne gère pas le drapeau. Les tests de capacité prennent le TEXTE de
|
||||
// l'aide, pas le binaire : buildServeArgs reste ainsi une fonction pure,
|
||||
// testable avec une aide fabriquée.
|
||||
func helpSupportsReasoningFlag(help string) bool {
|
||||
return strings.Contains(help, "--reasoning ")
|
||||
}
|
||||
|
||||
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
|
||||
@@ -56,20 +60,19 @@ func binHelp(bin string) string {
|
||||
return h
|
||||
}
|
||||
|
||||
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
|
||||
// helpSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
|
||||
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
|
||||
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
|
||||
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
|
||||
func binSupportsLoadMode(bin string) bool {
|
||||
return strings.Contains(binHelp(bin), "--load-mode")
|
||||
func helpSupportsLoadMode(help string) bool {
|
||||
return strings.Contains(help, "--load-mode")
|
||||
}
|
||||
|
||||
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
|
||||
// helpSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
|
||||
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
|
||||
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
|
||||
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
|
||||
func binSupportsNGLAuto(bin string) bool {
|
||||
h := binHelp(bin)
|
||||
func helpSupportsNGLAuto(h string) bool {
|
||||
i := strings.Index(h, "--n-gpu-layers")
|
||||
if i < 0 {
|
||||
i = strings.Index(h, "-ngl")
|
||||
@@ -84,16 +87,16 @@ func binSupportsNGLAuto(bin string) bool {
|
||||
return strings.Contains(h[i:end], "'auto'")
|
||||
}
|
||||
|
||||
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
|
||||
// helpFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
|
||||
//
|
||||
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
|
||||
// La lecture fine de l'aide (helpSupportsNGLAuto) reste la source de vérité, mais
|
||||
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
|
||||
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
|
||||
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
|
||||
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
|
||||
// grossier mais insensible à la mise en page.
|
||||
func binFitsLayersItself(bin string) bool {
|
||||
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
|
||||
func helpFitsLayersItself(help string) bool {
|
||||
return helpSupportsNGLAuto(help) || helpSupportsLoadMode(help)
|
||||
}
|
||||
|
||||
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
|
||||
@@ -140,22 +143,37 @@ func hasAnyFlag(args []string, flags ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
|
||||
// --load-mode, sur les moteurs qui le connaissent :
|
||||
// normalizeLoadFlags choisit, au lancement, la forme des drapeaux de chargement
|
||||
// que CE binaire comprend. llama.cpp récent a remplacé --mlock / --no-mmap par
|
||||
// --load-mode (et refuse les anciens) ; un moteur ancien ou un fork ne connaît
|
||||
// que les anciens. L'éditeur écrit --load-mode, un preset d'avant peut porter
|
||||
// les anciens : on traduit dans le sens qu'il faut (repris d'AJEAN 0.16.0).
|
||||
//
|
||||
// --mlock → --load-mode mlock (garder le modèle en RAM)
|
||||
// --no-mmap → --load-mode none (tout charger, pas de mmap)
|
||||
// --mmap → --load-mode mmap
|
||||
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
|
||||
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
|
||||
//
|
||||
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
|
||||
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
|
||||
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
|
||||
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
|
||||
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||
// --mlock seul → mmap+mlock (l'ancien sens de --mlock : mmap conservé)
|
||||
// --no-mmap seul → none
|
||||
// --mlock + --no-mmap → mlock
|
||||
// --mmap → mmap
|
||||
//
|
||||
// ⚠️ Jusqu'ici --mlock seul devenait « mlock », qui COUPE le mmap : le modèle
|
||||
// entier montait en RAM au lieu d'être mappé — un OOM assuré sur un modèle plus
|
||||
// gros que la mémoire. Un --load-mode déjà écrit gagne sur tout : on retire
|
||||
// alors simplement les vieux drapeaux.
|
||||
//
|
||||
// Vers les anciens drapeaux (moteur qui ne connaît pas --load-mode) :
|
||||
//
|
||||
// none → --no-mmap · mlock → --mlock --no-mmap · mmap+mlock → --mlock
|
||||
// auto, mmap, dio → rien (défaut de l'ancien moteur)
|
||||
//
|
||||
// La note éventuelle (dio abandonné) est RENDUE, pas affichée : buildServeArgs
|
||||
// reste pure et c'est cmdServe qui l'écrit, à sa place parmi les autres notes.
|
||||
func normalizeLoadFlags(args []string, supportsLoadMode bool) ([]string, string) {
|
||||
if !supportsLoadMode {
|
||||
return args
|
||||
return downgradeLoadMode(args)
|
||||
}
|
||||
explicit := hasAnyFlag(args, "--load-mode")
|
||||
explicit := hasAnyFlag(args, "--load-mode", "-lm")
|
||||
mlock, nommap, mmap := false, false, false
|
||||
out := make([]string, 0, len(args)+2)
|
||||
for _, a := range args {
|
||||
@@ -171,21 +189,74 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||
}
|
||||
}
|
||||
if explicit || !(mlock || nommap || mmap) {
|
||||
return out
|
||||
return out, ""
|
||||
}
|
||||
mode := "mmap"
|
||||
switch {
|
||||
case mlock:
|
||||
case mlock && nommap:
|
||||
mode = "mlock"
|
||||
case mlock:
|
||||
mode = "mmap+mlock"
|
||||
case nommap:
|
||||
mode = "none"
|
||||
}
|
||||
return append(out, "--load-mode", mode)
|
||||
return append(out, "--load-mode", mode), ""
|
||||
}
|
||||
|
||||
// downgradeLoadMode retraduit un --load-mode pour un moteur qui ne le connaît
|
||||
// pas : le lui passer tel quel le ferait sortir en erreur au démarrage.
|
||||
func downgradeLoadMode(args []string) ([]string, string) {
|
||||
mode, found := "", false
|
||||
kept := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--load-mode" || a == "-lm" {
|
||||
found = true
|
||||
if i+1 < len(args) {
|
||||
mode = strings.ToLower(args[i+1])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "--load-mode="); ok {
|
||||
found, mode = true, strings.ToLower(v)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, a)
|
||||
}
|
||||
if !found {
|
||||
return args, ""
|
||||
}
|
||||
add := func(flag string) {
|
||||
for _, a := range kept {
|
||||
if a == flag {
|
||||
return
|
||||
}
|
||||
}
|
||||
kept = append(kept, flag)
|
||||
}
|
||||
switch mode {
|
||||
case "none":
|
||||
add("--no-mmap")
|
||||
case "mlock":
|
||||
add("--mlock")
|
||||
add("--no-mmap")
|
||||
case "mmap+mlock":
|
||||
add("--mlock")
|
||||
case "dio":
|
||||
return kept, "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut"
|
||||
}
|
||||
return kept, ""
|
||||
}
|
||||
|
||||
// cmdServe replaces the historic start.sh: read config.env, build the
|
||||
// llama-server invocation, and exec it (replacing this process so systemd
|
||||
// supervises llama-server directly).
|
||||
//
|
||||
// Tout ce qui touche au monde (fichiers, aide du moteur, variables
|
||||
// d'environnement, port, exec) reste ICI ; la composition de la ligne de
|
||||
// commande vit dans buildServeArgs, pure, pour qu'un changement de drapeau se
|
||||
// vérifie en table de tests plutôt qu'en relançant un moteur.
|
||||
func cmdServe(args []string) error {
|
||||
cfg := ReadConfig()
|
||||
bin := cfg["BIN"]
|
||||
@@ -230,40 +301,237 @@ func cmdServe(args []string) error {
|
||||
// Linux, PATH on Windows — handled inside execServer.
|
||||
setLibraryPath(filepath.Dir(bin))
|
||||
|
||||
// Sélection GPU (loki gpu) : on filtre les devices visibles par llama-server.
|
||||
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
|
||||
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
|
||||
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
|
||||
_ = os.Setenv("CUDA_VISIBLE_DEVICES", v)
|
||||
_ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
|
||||
// La sélection GPU est posée AVANT de lire l'aide du moteur, comme elle l'a
|
||||
// toujours été : le moteur interrogé voit déjà les mêmes devices que celui
|
||||
// qu'on lancera. buildServeArgs la renvoie aussi dans env (même valeur).
|
||||
applyServeEnv(cudaDeviceEnv(cfg))
|
||||
|
||||
si := serveSysInfo{Help: binHelp(bin), Model: model}
|
||||
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
|
||||
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
|
||||
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
|
||||
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
|
||||
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
|
||||
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
|
||||
// mourir en boucle sur un « failed to load mmproj » cryptique.
|
||||
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
|
||||
mmPath, err := resolveServeModelPath(mm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
|
||||
}
|
||||
if _, err := os.Stat(mmPath); err != nil {
|
||||
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
|
||||
}
|
||||
si.MMProj = mmPath
|
||||
}
|
||||
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
||||
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
|
||||
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
|
||||
si.APIKey, _ = effectiveAPIKeyErr()
|
||||
// Conteneur à l'étroit (cpuset, quota) : seul Linux l'expose, dans /proc et
|
||||
// /sys. Un LLAMA_ARG_THREADS déjà posé est un choix de l'utilisateur — un -t
|
||||
// de Loki l'écraserait, donc on ne sonde même pas.
|
||||
if runtime.GOOS == "linux" && os.Getenv("LLAMA_ARG_THREADS") == "" {
|
||||
si.CPU = cpusetThreads(os.DirFS("/"))
|
||||
}
|
||||
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
// Deux GPU ou plus : de quoi décider des files de lancement CUDA (voir
|
||||
// launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse.
|
||||
probeServeGPUs(cfg, extra, &si)
|
||||
probeExpertEnv(&si)
|
||||
probeFidelityEnv(&si)
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
probeCkptEnv(&si)
|
||||
spec := specMode(cfg)
|
||||
if spec == "auto" || spec == "mtp" {
|
||||
probeSpec(cfg, &si)
|
||||
}
|
||||
// Build du moteur : seulement pour un hybride ou SPEC=auto, les seuls cas où
|
||||
// il décide de quelque chose (voir ckptArgs, specAutoBlocker) — inutile de
|
||||
// lancer « --version » sinon.
|
||||
if ggufHybrid(si.GGUF) || spec == "auto" {
|
||||
si.EngineBuild = engineBuildTrusted(bin)
|
||||
}
|
||||
// Lu seulement ici ; rien n'est consommé avant que le port soit libre.
|
||||
specMark := specAutoMark{FP: configFingerprint(cfg), Bin: bin, Build: si.EngineBuild}
|
||||
specRecord := false
|
||||
if spec == "auto" {
|
||||
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
|
||||
}
|
||||
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
|
||||
si.SlotDir = prepareSlotDir(LokiHome())
|
||||
}
|
||||
|
||||
llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si)
|
||||
applyServeEnv(env)
|
||||
for _, n := range notes {
|
||||
fmt.Fprintln(os.Stderr, "[loki serve] "+n)
|
||||
}
|
||||
|
||||
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
||||
// mmproj-F16.gguf) still resolve.
|
||||
_ = os.Chdir(LokiHome())
|
||||
|
||||
// Port déjà pris (souvent un llama-server orphelin qu'un stop n'a pas pu
|
||||
// tuer) : deux moteurs sur un même port se partagent les requêtes au hasard,
|
||||
// VRAM saturée et réponses du mauvais modèle. On refuse en clair.
|
||||
host, port := argValue(llmArgs, "--host"), argValue(llmArgs, "--port")
|
||||
if err := waitPortFree(host, port, 5*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
// Le type EFFECTIF : un -ctk/-ctv d'EXTRA_ARGS l'emporte sur KV_TYPE, et c'est
|
||||
// lui que le moteur accélère ou non.
|
||||
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
warnSlowKV(kt, vt)
|
||||
// Jeton de tentative : l'ancien consommé (et l'échec inscrit), le nouveau
|
||||
// posé, au dernier moment — port libre, l'ancien moteur est donc bien parti
|
||||
// et le process web ne peut pas le confondre avec lui.
|
||||
if spec == "auto" {
|
||||
_, _, auto := specArgs(cfg, extra, si)
|
||||
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), port)
|
||||
|
||||
// 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.
|
||||
return execServer(bin, llmArgs)
|
||||
}
|
||||
|
||||
// serveSysInfo rassemble ce que cmdServe a dû demander au monde avant de
|
||||
// composer la ligne de commande : l'aide du moteur (d'où se déduisent ses
|
||||
// capacités), les chemins déjà résolus et vérifiés, la clé d'API. Avec elle,
|
||||
// buildServeArgs n'a plus besoin de toucher ni au disque ni au binaire.
|
||||
type serveSysInfo struct {
|
||||
Help string // sortie de « <bin> --help » ; vide = moteur inconnu, aucun risque pris
|
||||
Model string // chemin du .gguf principal, résolu et vérifié
|
||||
MMProj string // chemin du projecteur vision, résolu et vérifié ; vide = pas de vision
|
||||
APIKey string // clé effective (.api_key, sinon config.env) ; vide = serveur ouvert
|
||||
CPU cpuBudget // sonde de conteneur (Linux) ; zéro = llama.cpp choisit ses threads seul
|
||||
|
||||
LaunchQueues string // CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement : choix de l'utilisateur, intouché
|
||||
GPUs int // GPU visibles (CUDA_VISIBLE_DEVICES, sinon nvidia-smi) ; 0 = inconnu ou non sondé
|
||||
ArgEnv map[string]string // LLAMA_ARG_* de l'environnement qui décident du pipeline, de --fit et du cache (serveArgEnv, fitArgEnv, fidelityArgEnv)
|
||||
UserEnv map[string]string // GGML_* des réglages d'expert déjà posés (voir expertEnvKeys) : intouchés
|
||||
|
||||
// Cache de prompts (voir backend_serve_cache.go). Zéro = inconnu : le moteur
|
||||
// garde son défaut.
|
||||
GGUF *GGUFInfo // métadonnées du modèle ; nil = illisibles
|
||||
ModelBytes int64 // taille du modèle, toutes tranches
|
||||
RAMMiB int64 // RAM de la machine, limite du conteneur comprise
|
||||
RAMAvailMiB int64 // RAM libre au lancement
|
||||
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
|
||||
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
|
||||
// automatique.
|
||||
EngineBuild int
|
||||
|
||||
// Décodage spéculatif (voir backend_serve_spec.go), lu seulement si SPEC le
|
||||
// demande.
|
||||
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
|
||||
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
|
||||
}
|
||||
|
||||
// cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par
|
||||
// llama-server. CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index
|
||||
// correspondent à ceux affichés par nvidia-smi (sinon CUDA réordonne par
|
||||
// « device le plus rapide »).
|
||||
func cudaDeviceEnv(cfg map[string]string) map[string]string {
|
||||
env := map[string]string{}
|
||||
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
|
||||
env["CUDA_VISIBLE_DEVICES"] = v
|
||||
env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func applyServeEnv(env map[string]string) {
|
||||
for k, v := range env {
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// serveKVTypes donne les types de cache K et V du preset : KV_TYPE pour les
|
||||
// deux, KV_TYPE_K / KV_TYPE_V pour les régler séparément. Vide = défaut moteur.
|
||||
func serveKVTypes(cfg map[string]string) (k, v string) {
|
||||
kv := cfg["KV_TYPE"]
|
||||
k, v = kv, kv
|
||||
if s := cfg["KV_TYPE_K"]; s != "" {
|
||||
k = s
|
||||
}
|
||||
if s := cfg["KV_TYPE_V"]; s != "" {
|
||||
v = s
|
||||
}
|
||||
return k, v
|
||||
}
|
||||
|
||||
// buildServeArgs compose la ligne de commande de llama-server (bin en tête) à
|
||||
// partir du preset, d'EXTRA_ARGS tel que découpé par splitArgs et de ce que
|
||||
// cmdServe a sondé. Fonction pure : elle renvoie aussi les variables
|
||||
// d'environnement à poser et les notes à afficher, sans rien faire elle-même.
|
||||
// L'ordre des arguments est celui qu'a toujours produit cmdServe — le moteur
|
||||
// retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre fait partie du
|
||||
// comportement.
|
||||
func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveSysInfo) (args []string, env map[string]string, notes []string) {
|
||||
get := func(key, fallback string) string {
|
||||
if v, ok := cfg[key]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
kv := get("KV_TYPE", "")
|
||||
ktv := get("KV_TYPE_K", kv)
|
||||
vtv := get("KV_TYPE_V", kv)
|
||||
ktv, vtv := serveKVTypes(cfg)
|
||||
env = cudaDeviceEnv(cfg)
|
||||
|
||||
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
|
||||
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
|
||||
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
|
||||
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
|
||||
extra := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
|
||||
extra, loadNote := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
|
||||
if loadNote != "" {
|
||||
notes = append(notes, loadNote)
|
||||
}
|
||||
|
||||
llmArgs := []string{bin,
|
||||
"-m", model,
|
||||
// Files de lancement CUDA : une variable d'environnement, pas un drapeau —
|
||||
// la ligne de commande n'en dépend pas (voir launchQueuesEnv).
|
||||
q, qNotes := launchQueuesEnv(cfg, extra, si)
|
||||
if q != "" {
|
||||
env["CUDA_SCALE_LAUNCH_QUEUES"] = q
|
||||
}
|
||||
notes = append(notes, qNotes...)
|
||||
// Réglages d'expert passés par variable (voir backend_serve_expert.go) :
|
||||
// absents du preset, ils ne posent rien.
|
||||
g, gNotes := graphOptEnv(cfg, extra, si)
|
||||
if g != "" {
|
||||
env["GGML_CUDA_GRAPH_OPT"] = g
|
||||
}
|
||||
o, oNotes := opOffloadMinBatchEnv(cfg, si)
|
||||
if o != "" {
|
||||
env["GGML_OP_OFFLOAD_MIN_BATCH"] = o
|
||||
}
|
||||
notes = append(append(notes, gNotes...), oNotes...)
|
||||
|
||||
// Threads : vide ou 0 = AUCUN drapeau, pour que llama.cpp prenne ses cœurs
|
||||
// physiques au lieu de tous les threads logiques (voir threadArgs).
|
||||
threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU)
|
||||
notes = append(notes, threadNotes...)
|
||||
args = []string{bin,
|
||||
"-m", si.Model,
|
||||
"-c", get("CTX", "32768"),
|
||||
"-t", get("THREADS", "0"),
|
||||
"-tb", get("THREADS_BATCH", "0"),
|
||||
}
|
||||
args = append(args, threads...)
|
||||
args = append(args,
|
||||
"-b", get("BATCH", "2048"),
|
||||
"-ub", get("UBATCH", "512"),
|
||||
"--host", get("HOST", "0.0.0.0"),
|
||||
"--port", get("PORT", "8080"),
|
||||
}
|
||||
)
|
||||
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
|
||||
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
|
||||
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
|
||||
@@ -273,7 +541,7 @@ func cmdServe(args []string) error {
|
||||
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
|
||||
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
|
||||
if !hasAnyFlag(extra, "--parallel", "-np") {
|
||||
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
|
||||
args = append(args, "--parallel", get("PARALLEL", "1"))
|
||||
}
|
||||
// Couches GPU. Quatre cas, dans cet ordre :
|
||||
//
|
||||
@@ -296,34 +564,26 @@ func cmdServe(args []string) error {
|
||||
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
|
||||
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
|
||||
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
|
||||
ngl, note := nglArgs(get("NGL", ""), helpFitsLayersItself(si.Help))
|
||||
if note != "" {
|
||||
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
|
||||
notes = append(notes, note)
|
||||
}
|
||||
llmArgs = append(llmArgs, args...)
|
||||
args = append(args, ngl...)
|
||||
}
|
||||
// Marge VRAM par carte du placement automatique (FIT_TARGET, voir
|
||||
// fitTargetArgs) : seulement si --fit tournera vraiment.
|
||||
fitt, fitNotes := fitTargetArgs(cfg, extra, si)
|
||||
args = append(args, fitt...)
|
||||
notes = append(notes, fitNotes...)
|
||||
if ktv != "" {
|
||||
llmArgs = append(llmArgs, "-ctk", ktv)
|
||||
args = append(args, "-ctk", ktv)
|
||||
}
|
||||
if vtv != "" {
|
||||
llmArgs = append(llmArgs, "-ctv", vtv)
|
||||
args = append(args, "-ctv", vtv)
|
||||
}
|
||||
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
|
||||
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
|
||||
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
|
||||
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
|
||||
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
|
||||
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
|
||||
// mourir en boucle sur un « failed to load mmproj » cryptique.
|
||||
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
|
||||
mmPath, err := resolveServeModelPath(mm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
|
||||
}
|
||||
if _, err := os.Stat(mmPath); err != nil {
|
||||
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
|
||||
}
|
||||
llmArgs = append(llmArgs, "--mmproj", mmPath)
|
||||
// Vision : chemin déjà résolu et vérifié par cmdServe (voir là-bas).
|
||||
if si.MMProj != "" {
|
||||
args = append(args, "--mmproj", si.MMProj)
|
||||
}
|
||||
// Raisonnement. Trois cas, et la nuance compte :
|
||||
//
|
||||
@@ -343,37 +603,114 @@ func cmdServe(args []string) error {
|
||||
// contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
|
||||
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
|
||||
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
|
||||
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
|
||||
} else if binSupportsReasoningFlag(bin) {
|
||||
args = append(args, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
|
||||
} else if helpSupportsReasoningFlag(si.Help) {
|
||||
// Pas de budget ici : « off » suffit, et un budget sur un moteur qui
|
||||
// n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer.
|
||||
llmArgs = append(llmArgs, "--reasoning", "off")
|
||||
args = append(args, "--reasoning", "off")
|
||||
} else {
|
||||
// Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
|
||||
// le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
|
||||
// continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
|
||||
notes = append(notes, "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement")
|
||||
}
|
||||
}
|
||||
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
||||
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
|
||||
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
|
||||
if k, _ := effectiveAPIKeyErr(); k != "" {
|
||||
llmArgs = append(llmArgs, "--api-key", k)
|
||||
// Conservation du raisonnement des tours passés : seulement si
|
||||
// REASONING_PRESERVE est posée (voir reasoningPreserveArgs pour le compromis).
|
||||
rp, rpNotes := reasoningPreserveArgs(cfg, extra, si)
|
||||
args = append(args, rp...)
|
||||
notes = append(notes, rpNotes...)
|
||||
if si.APIKey != "" {
|
||||
args = append(args, "--api-key", si.APIKey)
|
||||
}
|
||||
// Cache de prompts en RAM et dossier des slots (voir backend_serve_cache.go) :
|
||||
// avant EXTRA_ARGS, qui garde le dernier mot.
|
||||
cram, cramNotes := cacheRAMArgs(cfg, extra, si)
|
||||
args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...)
|
||||
notes = append(notes, cramNotes...)
|
||||
// Points de reprise des hybrides (voir backend_serve_ckpt.go).
|
||||
ck, ckNotes := ckptArgs(cfg, extra, si)
|
||||
args = append(args, ck...)
|
||||
notes = append(notes, ckNotes...)
|
||||
// Décodage spéculatif (voir backend_serve_spec.go) : rien tant que SPEC
|
||||
// n'est pas posé.
|
||||
sp, spNotes, _ := specArgs(cfg, extra, si)
|
||||
args = append(args, sp...)
|
||||
notes = append(notes, spNotes...)
|
||||
// EXTRA_ARGS (déjà découpé comme le ferait le shell — les guillemets gardent
|
||||
// ensemble un chemin qui contient des espaces) ferme la marche.
|
||||
args = append(args, extra...)
|
||||
// Garde-fous de fidélité (voir backend_serve_fidelity.go) : ils ne touchent à
|
||||
// aucun drapeau, ils DISENT ce que la ligne finale change aux calculs.
|
||||
ek, ev, src := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
if n := kvFidelityNote(ek, ev, src, extra, si.ArgEnv); n != "" {
|
||||
notes = append(notes, n)
|
||||
}
|
||||
notes = append(notes, lossyCacheNotes(extra, si)...)
|
||||
return args, env, notes
|
||||
}
|
||||
|
||||
// argValue renvoie la valeur de la DERNIÈRE occurrence de flag (llama-server
|
||||
// retient la dernière : EXTRA_ARGS peut surcharger --port).
|
||||
func argValue(args []string, flag string) string {
|
||||
v := ""
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag {
|
||||
v = args[i+1]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// waitPortFree vérifie que personne n'écoute déjà sur host:port. On tente une
|
||||
// connexion TCP (fiable même si l'occupant a bindé en SO_REUSEADDR, où un
|
||||
// Listen de test pourrait réussir). Un moteur qu'on vient d'arrêter peut tenir
|
||||
// le port quelques instants : on réessaie jusqu'à wait avant de conclure.
|
||||
// Repris d'AJEAN 0.15.9.
|
||||
func waitPortFree(host, port string, wait time.Duration) error {
|
||||
if port == "" {
|
||||
return nil
|
||||
}
|
||||
h := strings.Trim(host, "[]")
|
||||
if h == "" || h == "0.0.0.0" || h == "::" {
|
||||
h = "127.0.0.1"
|
||||
}
|
||||
addr := net.JoinHostPort(h, port)
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c.Close()
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("le port %s est déjà utilisé (sans doute un ancien llama-server encore actif) : "+
|
||||
"« loki stop », docker restart loki, ou change PORT", port)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// fastKV : combinaisons K/V que llama.cpp CUDA accélère en Flash-Attention
|
||||
// avec ses options de compilation par défaut (GGML_CUDA_FA_ALL_QUANTS=OFF) —
|
||||
// c'est le cas du moteur de l'image server-cuda comme de ceux tirés par OCI.
|
||||
// Les autres (q5_*, q4_1, combinaisons mixtes) marchent mais sont reconverties
|
||||
// en f16 à chaque pas : la génération ralentit nettement (AJEAN 0.15.9).
|
||||
var fastKV = map[string]bool{"f16|f16": true, "q8_0|q8_0": true, "q4_0|q4_0": true, "bf16|bf16": true}
|
||||
|
||||
func slowKV(k, v string) bool {
|
||||
if k == "" {
|
||||
k = "f16"
|
||||
}
|
||||
if v == "" {
|
||||
v = "f16"
|
||||
}
|
||||
return !fastKV[k+"|"+v]
|
||||
}
|
||||
|
||||
func warnSlowKV(k, v string) {
|
||||
if slowKV(k, v) {
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] avertissement : cache KV %s/%s non accéléré par llama.cpp CUDA "+
|
||||
"(converti en f16 à chaque pas, lent). Préfère q8_0/q8_0, q4_0/q4_0 ou f16.\n", k, v)
|
||||
}
|
||||
// EXTRA_ARGS (déjà découpé plus haut comme le ferait le shell — les guillemets
|
||||
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
|
||||
llmArgs = append(llmArgs, extra...)
|
||||
|
||||
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
||||
// mmproj-F16.gguf) still resolve.
|
||||
_ = os.Chdir(LokiHome())
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), get("PORT", "8080"))
|
||||
|
||||
// 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.
|
||||
return execServer(bin, llmArgs)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Deux aides de moteur fabriquées, réduites aux lignes que lisent les tests de
|
||||
// capacité : un llama.cpp récent (--load-mode, « -ngl auto », --reasoning) et
|
||||
// un ancien qui ne connaît que les drapeaux d'origine.
|
||||
const (
|
||||
helpRecent = `-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number,
|
||||
'auto', or 'all' (default: auto)
|
||||
-lm, --load-mode MODE how to load the model: auto, mmap, mmap+mlock, mlock, none, dio
|
||||
-rea, --reasoning [on|off|auto] use reasoning/thinking in the chat (default: auto)
|
||||
`
|
||||
helpOld = `-ngl, --gpu-layers, --n-gpu-layers N number of layers to store in VRAM
|
||||
--mlock force system to keep model in RAM
|
||||
--no-mmap do not memory-map model
|
||||
`
|
||||
)
|
||||
|
||||
// TestBuildServeArgs fige la ligne de commande produite pour les presets
|
||||
// courants : la moindre différence d'ordre ou de valeur casse le test. Le
|
||||
// moteur retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre compte
|
||||
// autant que les valeurs — c'est la base sur laquelle les réglages suivants
|
||||
// viendront s'ajouter, chacun avec sa ligne ici.
|
||||
func TestBuildServeArgs(t *testing.T) {
|
||||
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
|
||||
base := []string{bin, "-m", model,
|
||||
"-c", "32768", "-b", "2048", "-ub", "512",
|
||||
"--host", "0.0.0.0", "--port", "8080"}
|
||||
with := func(tail ...string) []string {
|
||||
return append(append([]string{}, base...), tail...)
|
||||
}
|
||||
// withThreads insère -t / -tb à leur place, juste après -c.
|
||||
withThreads := func(threads []string, tail ...string) []string {
|
||||
out := append(append([]string{}, base[:5]...), threads...)
|
||||
return append(append(out, base[5:]...), tail...)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want []string
|
||||
wantEnv map[string]string
|
||||
wantNotes int
|
||||
}{
|
||||
{
|
||||
name: "dense par défaut, moteur récent : 999 devient auto, et on le dit",
|
||||
cfg: map[string]string{"NGL": "999"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "auto"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "dense par défaut, moteur ancien : 999 reste 999",
|
||||
cfg: map[string]string{"NGL": "999"},
|
||||
si: serveSysInfo{Help: helpOld},
|
||||
want: with("--parallel", "1", "-ngl", "999"),
|
||||
},
|
||||
{
|
||||
name: "clé NGL absente, aide illisible : forme historique",
|
||||
cfg: map[string]string{},
|
||||
si: serveSysInfo{},
|
||||
want: with("--parallel", "1", "-ngl", "999"),
|
||||
},
|
||||
{
|
||||
name: "NGL forcé à un nombre : jamais touché",
|
||||
cfg: map[string]string{"NGL": "28"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "NGL=auto : aucun drapeau",
|
||||
cfg: map[string]string{"NGL": "auto"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1"),
|
||||
},
|
||||
{
|
||||
name: "réglages du preset et cache KV commun",
|
||||
cfg: map[string]string{"CTX": "65536", "THREADS": "8", "THREADS_BATCH": "16", "BATCH": "4096",
|
||||
"UBATCH": "1024", "HOST": "127.0.0.1", "PORT": "9090", "PARALLEL": "2", "NGL": "all",
|
||||
"KV_TYPE": "q8_0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: []string{bin, "-m", model,
|
||||
"-c", "65536", "-t", "8", "-tb", "16", "-b", "4096", "-ub", "1024",
|
||||
"--host", "127.0.0.1", "--port", "9090",
|
||||
"--parallel", "2", "-ngl", "all", "-ctk", "q8_0", "-ctv", "q8_0"},
|
||||
wantNotes: 1, // cache quantifié : dit, jamais retiré
|
||||
},
|
||||
{
|
||||
name: "THREADS=0 et THREADS_BATCH=0 : aucun drapeau, llama.cpp prend ses cœurs physiques",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "0", "THREADS_BATCH": "0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "THREADS=6 seul : -t 6, -tb laissé au moteur (il recopie -t)",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "THREADS_BATCH seul : -tb sans -t",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS_BATCH": "12"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-tb", "12"}, "--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "THREADS illisible ou négatif : ignoré, et dit",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "auto", "THREADS_BATCH": "-1"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 2,
|
||||
},
|
||||
{
|
||||
name: "-t=6 dans EXTRA_ARGS : pas de -t de Loki",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "8", "THREADS_BATCH": "16", "EXTRA_ARGS": "-t=6 --threads-batch 10"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-t=6", "--threads-batch", "10"),
|
||||
},
|
||||
{
|
||||
// Sans -tb, llama.cpp remplacerait tout le réglage CPU du batch par
|
||||
// celui de -t : le --poll-batch de l'utilisateur serait effacé.
|
||||
name: "--poll-batch dans EXTRA_ARGS, THREADS vide : -tb 0 le protège, et on le dit",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--poll-batch 0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-tb", "0"}, "--parallel", "1", "-ngl", "28", "--poll-batch", "0"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "-Cb dans EXTRA_ARGS, THREADS=6 : -tb recopie 6",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "6", "EXTRA_ARGS": "-Cb 0xff"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-t", "6", "-tb", "6"}, "--parallel", "1", "-ngl", "28", "-Cb", "0xff"),
|
||||
},
|
||||
{
|
||||
name: "-t=5 et --prio-batch dans EXTRA_ARGS : -tb recopie 5",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-t=5 --prio-batch 2"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-tb", "5"}, "--parallel", "1", "-ngl", "28", "-t=5", "--prio-batch", "2"),
|
||||
},
|
||||
{
|
||||
name: "-Crb et -tb dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Crb 0-7 -tb 8"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-Crb", "0-7", "-tb", "8"),
|
||||
},
|
||||
{
|
||||
name: "conteneur à l'étroit : -t de la sonde, et une note",
|
||||
cfg: map[string]string{"NGL": "28"},
|
||||
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota cgroup de 4 CPU"}},
|
||||
want: withThreads([]string{"-t", "4"}, "--parallel", "1", "-ngl", "28"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "THREADS explicite : la sonde se tait",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
|
||||
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
|
||||
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "masque d'affinité dans EXTRA_ARGS : la sonde se tait",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Cr 0-3"},
|
||||
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-Cr", "0-3"),
|
||||
},
|
||||
{
|
||||
name: "cache KV séparé K/V",
|
||||
cfg: map[string]string{"NGL": "28", "KV_TYPE": "q8_0", "KV_TYPE_V": "q4_0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-ctk", "q8_0", "-ctv", "q4_0"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
// Preset MoE typique : experts sur CPU via EXTRA_ARGS, qui pose aussi
|
||||
// --parallel, -ngl et le cache KV. Loki n'ajoute alors ni --parallel ni
|
||||
// -ngl, et EXTRA_ARGS ferme la marche (la dernière occurrence gagne).
|
||||
name: "MoE : EXTRA_ARGS avec -ot, --n-cpu-moe, -ctk q8_0",
|
||||
cfg: map[string]string{"NGL": "999", "PARALLEL": "1",
|
||||
"EXTRA_ARGS": `-ngl 99 --parallel 1 -ot "blk\.(\d+)\.ffn_.*_exps=CPU" --n-cpu-moe 30 -ctk q8_0 -ctv q8_0 -fa on`},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("-ngl", "99", "--parallel", "1", "-ot", `blk\.(\d+)\.ffn_.*_exps=CPU`,
|
||||
"--n-cpu-moe", "30", "-ctk", "q8_0", "-ctv", "q8_0", "-fa", "on"),
|
||||
wantNotes: 1, // q8_0 venu d'EXTRA_ARGS : vu comme celui de KV_TYPE
|
||||
},
|
||||
{
|
||||
name: "--n-gpu-layers=N dans EXTRA_ARGS : pas de -ngl en double",
|
||||
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "--n-gpu-layers=40 -np 2"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--n-gpu-layers=40", "-np", "2"),
|
||||
},
|
||||
{
|
||||
name: "raisonnement actif : budget illimité par défaut",
|
||||
cfg: map[string]string{"NGL": "28", "REASONING": "on"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "on", "--reasoning-budget", "-1"),
|
||||
},
|
||||
{
|
||||
name: "raisonnement auto avec budget choisi",
|
||||
cfg: map[string]string{"NGL": "28", "REASONING": " auto ", "REASONING_BUDGET": "4096"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "auto", "--reasoning-budget", "4096"),
|
||||
},
|
||||
{
|
||||
name: "raisonnement interdit, moteur qui connaît le drapeau",
|
||||
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "off"),
|
||||
},
|
||||
{
|
||||
name: "raisonnement interdit, vieux moteur : aucun drapeau, une note",
|
||||
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
|
||||
si: serveSysInfo{Help: helpOld},
|
||||
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "vision et clé d'API, avant EXTRA_ARGS",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-fa on"},
|
||||
si: serveSysInfo{Help: helpRecent, MMProj: "/models/mmproj-F16.gguf", APIKey: "secret"},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--mmproj", "/models/mmproj-F16.gguf",
|
||||
"--api-key", "secret", "-fa", "on"),
|
||||
},
|
||||
{
|
||||
name: "drapeaux de chargement anciens → --load-mode sur moteur récent",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--mlock --no-mmap -fa on"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-fa", "on", "--load-mode", "mlock"),
|
||||
},
|
||||
{
|
||||
name: "--load-mode retraduit pour un moteur ancien",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode none"},
|
||||
si: serveSysInfo{Help: helpOld},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--no-mmap"),
|
||||
},
|
||||
{
|
||||
// dio n'existe pas sur l'ancien moteur : retiré, et dit par une note
|
||||
// RENDUE (buildServeArgs n'écrit rien elle-même).
|
||||
name: "--load-mode dio sur moteur ancien : retiré, une note",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode dio -fa on"},
|
||||
si: serveSysInfo{Help: helpOld},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-fa", "on"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "sélection GPU : variables d'environnement, ligne inchangée",
|
||||
cfg: map[string]string{"NGL": "28", "CUDA_VISIBLE_DEVICES": "1,0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28"),
|
||||
wantEnv: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID"},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
c.si.Model = model
|
||||
args, env, notes := buildServeArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), bin, c.si)
|
||||
if !reflect.DeepEqual(args, c.want) {
|
||||
t.Fatalf("args\n got %q\nwant %q", args, c.want)
|
||||
}
|
||||
wantEnv := c.wantEnv
|
||||
if wantEnv == nil {
|
||||
wantEnv = map[string]string{}
|
||||
}
|
||||
if !reflect.DeepEqual(env, wantEnv) {
|
||||
t.Fatalf("env = %v, want %v", env, wantEnv)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpCapabilities(t *testing.T) {
|
||||
if !helpSupportsReasoningFlag(helpRecent) || helpSupportsReasoningFlag(helpOld) {
|
||||
t.Fatal("--reasoning mal détecté")
|
||||
}
|
||||
if !helpSupportsLoadMode(helpRecent) || helpSupportsLoadMode(helpOld) {
|
||||
t.Fatal("--load-mode mal détecté")
|
||||
}
|
||||
if !helpSupportsNGLAuto(helpRecent) || helpSupportsNGLAuto(helpOld) {
|
||||
t.Fatal("« -ngl auto » mal détecté")
|
||||
}
|
||||
// Aide illisible = aucune capacité : on garde la forme historique.
|
||||
if helpFitsLayersItself("") || helpSupportsReasoningFlag("") {
|
||||
t.Fatal("une aide vide ne doit promettre aucune capacité")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeKVTypes(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
cfg map[string]string
|
||||
k, v string
|
||||
}{
|
||||
{map[string]string{}, "", ""},
|
||||
{map[string]string{"KV_TYPE": "q8_0"}, "q8_0", "q8_0"},
|
||||
{map[string]string{"KV_TYPE": "q8_0", "KV_TYPE_K": "f16"}, "f16", "q8_0"},
|
||||
{map[string]string{"KV_TYPE_V": "q4_0"}, "", "q4_0"},
|
||||
} {
|
||||
if k, v := serveKVTypes(c.cfg); k != c.k || v != c.v {
|
||||
t.Errorf("%v : got %s/%s, want %s/%s", c.cfg, k, v, c.k, c.v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cache de prompts en RAM (--cache-ram) et isolation des travaux annexes.
|
||||
//
|
||||
// llama-server garde en RAM hôte une copie EXACTE des états de slot qu'il quitte
|
||||
// (cache KV, état récurrent, points de reprise, contexte du brouillon MTP) et la
|
||||
// recharge octet pour octet quand la conversation revient. Ce n'est pas une
|
||||
// approximation : la seule différence avec un prefill complet est le bruit de
|
||||
// virgule flottante dû à un découpage des lots différent, exactement comme le
|
||||
// cache_prompt par défaut. Le type du cache KV n'est pas touché, il est copié
|
||||
// tel quel.
|
||||
//
|
||||
// Le défaut du moteur (8 Gio) est trop petit pour une conversation longue d'un
|
||||
// modèle dense : l'état principal (30 à 65 k jetons) ne tient plus à côté de
|
||||
// celui d'un vérificateur ou d'un sous-agent, et la conversation est alors
|
||||
// recalculée en entier — 30 à 80 s sur un 27B. Loki agrandit donc ce cache,
|
||||
// mais seulement quand il sait mesurer le besoin et que la RAM est libre : il ne
|
||||
// descend JAMAIS sous le défaut du moteur, et se tait dès que l'utilisateur a
|
||||
// tranché (CACHE_RAM, -cram dans EXTRA_ARGS, LLAMA_ARG_CACHE_RAM).
|
||||
//
|
||||
// Tout ce qui décide est pur ; probeCacheRAM fait les lectures (GGUF, RAM,
|
||||
// nvidia-smi) une fois, avant buildServeArgs.
|
||||
|
||||
// engineCacheRAMDefault : cache_ram_mib par défaut de llama.cpp (common.h).
|
||||
const engineCacheRAMDefault = 8192
|
||||
|
||||
// cacheArgEnv : la variable qui règle le cache sans drapeau. llama.cpp donne la
|
||||
// priorité à la ligne de commande : un --cache-ram de Loki l'écraserait.
|
||||
var cacheArgEnv = []string{"LLAMA_ARG_CACHE_RAM"}
|
||||
|
||||
// probeCacheRAM complète si avec ce dont cacheRAMAuto a besoin. Chaque lecture
|
||||
// ratée laisse un zéro, et un zéro veut dire « on ne sait pas » : le moteur
|
||||
// garde alors son défaut.
|
||||
func probeCacheRAM(cfg map[string]string, extra []string, si *serveSysInfo) {
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range cacheArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
if si.Model != "" {
|
||||
si.ModelBytes = shardFamilySize(filepath.Dir(si.Model), filepath.Base(si.Model))
|
||||
if g, err := ggufMeta(si.Model); err == nil {
|
||||
si.GGUF = &g
|
||||
}
|
||||
}
|
||||
used, total := ramUsageMB()
|
||||
if total > 0 {
|
||||
si.RAMMiB, si.RAMAvailMiB = int64(total), int64(total-used)
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
if limit, cur := cgroupMemMiB(os.DirFS("/")); limit > 0 {
|
||||
if si.RAMMiB == 0 || limit < si.RAMMiB {
|
||||
si.RAMMiB = limit
|
||||
}
|
||||
if free := limit - cur; free >= 0 && (si.RAMAvailMiB == 0 || free < si.RAMAvailMiB) {
|
||||
si.RAMAvailMiB = free
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mémoire unifiée (macOS), AMD, Vulkan, --device choisi à la main : pas de
|
||||
// total VRAM fiable, donc pas d'agrandissement.
|
||||
if runtime.GOOS == "darwin" || hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
|
||||
return
|
||||
}
|
||||
// La sélection du preset d'abord (cudaDeviceEnv l'accompagne toujours de
|
||||
// PCI_BUS_ID), celle de l'environnement ensuite : l'aperçu de l'interface
|
||||
// tourne dans un process où le preset n'a rien posé.
|
||||
cvd, pci := cfg["CUDA_VISIBLE_DEVICES"], true
|
||||
if cvd == "" {
|
||||
cvd, pci = os.Getenv("CUDA_VISIBLE_DEVICES"), os.Getenv("CUDA_DEVICE_ORDER") == "PCI_BUS_ID"
|
||||
}
|
||||
si.VRAMMiB = nvidiaVRAMMiB(cvd, pci, 3*time.Second)
|
||||
}
|
||||
|
||||
// cgroupMemMiB : limite mémoire du conteneur et sa consommation, en Mio (v2,
|
||||
// sinon v1). 0 = pas de limite. Le MemAvailable de /proc/meminfo voit l'hôte
|
||||
// entier : sans ça, un conteneur limité à 16 Go sur une machine de 64 Go se
|
||||
// croirait riche.
|
||||
func cgroupMemMiB(fsys fs.FS) (limit, current int64) {
|
||||
read := func(p string) int64 {
|
||||
b, err := fs.ReadFile(fsys, p)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||
if err != nil || n <= 0 || n >= 1<<60 { // « max » ou la valeur géante du v1 sans limite
|
||||
return 0
|
||||
}
|
||||
return n >> 20
|
||||
}
|
||||
if limit = read("sys/fs/cgroup/memory.max"); limit > 0 {
|
||||
return limit, read("sys/fs/cgroup/memory.current")
|
||||
}
|
||||
if limit = read("sys/fs/cgroup/memory/memory.limit_in_bytes"); limit > 0 {
|
||||
return limit, read("sys/fs/cgroup/memory/memory.usage_in_bytes")
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// 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") {
|
||||
return 0
|
||||
}
|
||||
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 vramFromSMI(string(out), cvd, pciOrder)
|
||||
}
|
||||
|
||||
// vramFromSMI additionne la VRAM des cartes sélectionnées par
|
||||
// CUDA_VISIBLE_DEVICES. Les index de nvidia-smi sont dans l'ordre du bus PCI ;
|
||||
// ceux de CUDA aussi, mais seulement avec CUDA_DEVICE_ORDER=PCI_BUS_ID. Sans
|
||||
// lui, on ne sait pas quelle carte est laquelle : on prend la plus petite autant
|
||||
// de fois qu'il y a de cartes — un minorant, qui ne fait que rendre Loki plus
|
||||
// prudent. Un UUID ou une tranche MIG : on ne sait pas, 0.
|
||||
func vramFromSMI(out, cvd string, pciOrder bool) int64 {
|
||||
byIdx := map[int]int64{}
|
||||
var minMiB int64
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
f := strings.Split(line, ",")
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
i, err1 := strconv.Atoi(strings.TrimSpace(f[0]))
|
||||
m, err2 := strconv.ParseInt(strings.TrimSpace(f[1]), 10, 64)
|
||||
if err1 != nil || err2 != nil || m <= 0 {
|
||||
return 0
|
||||
}
|
||||
byIdx[i] = m
|
||||
if minMiB == 0 || m < minMiB {
|
||||
minMiB = m
|
||||
}
|
||||
}
|
||||
if len(byIdx) == 0 {
|
||||
return 0
|
||||
}
|
||||
cvd = strings.TrimSpace(cvd)
|
||||
if cvd == "" {
|
||||
var sum int64
|
||||
for _, m := range byIdx {
|
||||
sum += m
|
||||
}
|
||||
return sum
|
||||
}
|
||||
var sum int64
|
||||
n := 0
|
||||
for _, d := range strings.Split(cvd, ",") {
|
||||
if d = strings.TrimSpace(d); d == "" {
|
||||
continue
|
||||
}
|
||||
i, err := strconv.Atoi(d)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
m, ok := byIdx[i]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
sum += m
|
||||
n++
|
||||
}
|
||||
if !pciOrder {
|
||||
return minMiB * int64(n)
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// kvTypeBytes : octets par élément d'un type de cache (blocs de 32 pour les
|
||||
// types quantifiés de ggml). Inconnu : compté comme f16, l'estimation reste
|
||||
// bornée par le plafond de RAM.
|
||||
func kvTypeBytes(t string) float64 {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "f32":
|
||||
return 4
|
||||
case "q8_0":
|
||||
return 34.0 / 32
|
||||
case "q5_1":
|
||||
return 24.0 / 32
|
||||
case "q5_0":
|
||||
return 22.0 / 32
|
||||
case "q4_1":
|
||||
return 20.0 / 32
|
||||
case "q4_0", "iq4_nl":
|
||||
return 18.0 / 32
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// ggufStateBytes estime ce que pèse l'état d'une séquence : octets de cache KV
|
||||
// par jeton (couches d'attention), et octets d'état récurrent (couches SSM d'un
|
||||
// hybride, en f32 comme dans llama.cpp). 0 par jeton = on ne sait pas.
|
||||
func ggufStateBytes(g GGUFInfo, kt, vt string) (perToken float64, recurrent int64) {
|
||||
kl, vl := g.KeyLen, g.ValLen
|
||||
if g.EmbeddingLength > 0 && g.HeadCount > 0 {
|
||||
if kl == 0 {
|
||||
kl = g.EmbeddingLength / g.HeadCount
|
||||
}
|
||||
if vl == 0 {
|
||||
vl = g.EmbeddingLength / g.HeadCount
|
||||
}
|
||||
}
|
||||
if kl == 0 || vl == 0 || g.BlockCount == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
heads, recLayers := 0, 0
|
||||
switch {
|
||||
case len(g.HeadCountKVLayers) > 0:
|
||||
for _, h := range g.HeadCountKVLayers {
|
||||
heads += h
|
||||
if h == 0 && g.Hybrid {
|
||||
recLayers++
|
||||
}
|
||||
}
|
||||
case g.Hybrid && g.FullAttnInterval > 0:
|
||||
attn := g.BlockCount / g.FullAttnInterval
|
||||
heads, recLayers = attn*g.HeadCountKV, g.BlockCount-attn
|
||||
default:
|
||||
heads = g.BlockCount * g.HeadCountKV
|
||||
if heads == 0 && g.Hybrid {
|
||||
recLayers = g.BlockCount // récurrent pur
|
||||
}
|
||||
}
|
||||
perToken = float64(heads) * (float64(kl)*kvTypeBytes(kt) + float64(vl)*kvTypeBytes(vt))
|
||||
if recLayers > 0 {
|
||||
conv := int64(0)
|
||||
if g.SSMConv > 1 {
|
||||
conv = int64(g.SSMConv-1) * int64(g.SSMInner+2*g.SSMGroups*g.SSMState)
|
||||
}
|
||||
recurrent = int64(recLayers) * (conv + int64(g.SSMState)*int64(g.SSMInner)) * 4
|
||||
}
|
||||
return perToken, recurrent
|
||||
}
|
||||
|
||||
// serveCtx : la taille de contexte que le moteur ouvrira (-c d'EXTRA_ARGS, sinon
|
||||
// CTX, sinon le défaut de Loki ; 0 = celle d'entraînement du modèle).
|
||||
func serveCtx(cfg map[string]string, extra []string, g *GGUFInfo) int {
|
||||
s := flagValue(extra, "-c", "--ctx-size")
|
||||
if s == "" {
|
||||
s = strings.TrimSpace(cfg["CTX"])
|
||||
}
|
||||
if s == "" {
|
||||
s = "32768"
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
if n == 0 && g != nil {
|
||||
n = g.ContextLength
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// cpuWeights dit pourquoi des poids (ou le cache KV) vivent en RAM hôte. Le
|
||||
// cache de prompts y ferait alors concurrence au cache de pages des experts
|
||||
// mappés : on n'y touche pas. Vide = tout est sur GPU.
|
||||
func cpuWeights(cfg map[string]string, extra []string, argEnv map[string]string, blocks int) string {
|
||||
ot := flagValues(extra, "-ot", "--override-tensor")
|
||||
if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" {
|
||||
ot = append(ot, v)
|
||||
}
|
||||
for _, v := range ot {
|
||||
for _, el := range strings.Split(v, ",") {
|
||||
if _, buft, ok := strings.Cut(el, "="); ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU") {
|
||||
return "tenseurs sur CPU (-ot)"
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
|
||||
return "experts sur CPU"
|
||||
}
|
||||
for _, n := range []struct{ env, short, long string }{
|
||||
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
|
||||
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
|
||||
} {
|
||||
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
|
||||
if v = strings.TrimSpace(v); v != "" && v != "0" {
|
||||
return "couches " + n.long + " sur CPU"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !kvOffloaded(extra, argEnv) {
|
||||
return "cache KV sur CPU"
|
||||
}
|
||||
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
|
||||
if ngl == "" {
|
||||
ngl = strings.TrimSpace(cfg["NGL"])
|
||||
if strings.EqualFold(ngl, "auto") {
|
||||
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
|
||||
}
|
||||
}
|
||||
// 999 est la sentinelle « toutes » ; un nombre plus petit que le modèle (sa
|
||||
// couche de sortie comprise) laisse des couches au CPU.
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(ngl)); err == nil && n != 999 && (blocks == 0 || n < blocks+1) {
|
||||
return "couches GPU limitées à " + strconv.Itoa(n)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cacheRAMAuto calcule la taille du cache de prompts quand CACHE_RAM est vide
|
||||
// ou « auto ». 0 = garder le défaut du moteur, why dit pourquoi.
|
||||
//
|
||||
// Besoin : 2,5 fois l'état d'une conversation pleine (cache KV à CTX jetons,
|
||||
// plus l'état récurrent et ses points de reprise sur un hybride) — de quoi
|
||||
// garder la conversation principale ET l'état d'un travail annexe. Plafond : 30
|
||||
// % de la RAM (limite du conteneur comprise) et la moitié de ce qui est libre au
|
||||
// lancement. Ce plafond est un réglage, pas un filet : sous Linux, un
|
||||
// dépassement finit en général par l'OOM killer, pas par le repli du moteur.
|
||||
func cacheRAMAuto(cfg map[string]string, extra []string, si serveSysInfo) (mib int64, why string) {
|
||||
switch {
|
||||
case si.VRAMMiB <= 0:
|
||||
return 0, "VRAM NVIDIA inconnue"
|
||||
case si.GGUF == nil:
|
||||
return 0, "métadonnées GGUF illisibles"
|
||||
case si.RAMMiB <= 0 || si.RAMAvailMiB <= 0:
|
||||
return 0, "RAM inconnue"
|
||||
case si.ModelBytes <= 0:
|
||||
return 0, "taille du modèle inconnue"
|
||||
}
|
||||
if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" {
|
||||
return 0, r
|
||||
}
|
||||
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
perTok, rec := ggufStateBytes(*si.GGUF, kt, vt)
|
||||
ctx := serveCtx(cfg, extra, si.GGUF)
|
||||
if perTok <= 0 || ctx <= 0 {
|
||||
return 0, "taille d'état inconnue"
|
||||
}
|
||||
kvBytes := perTok * float64(ctx)
|
||||
if float64(si.ModelBytes)+kvBytes > 0.9*float64(si.VRAMMiB)*(1<<20) {
|
||||
return 0, "modèle trop gros pour la VRAM : des poids restent en RAM"
|
||||
}
|
||||
state := kvBytes
|
||||
if rec > 0 {
|
||||
// Points de reprise : llama.cpp en garde jusqu'à 32 par séquence ; on en
|
||||
// compte un par tranche de 8 k jetons, estimation grossière mais bornée.
|
||||
state += float64(rec) * float64(min(32, ctx/8192+1)+1)
|
||||
}
|
||||
need := int64(math.Ceil(2.5 * state / (1 << 20)))
|
||||
ceiling := min(si.RAMMiB*30/100, si.RAMAvailMiB/2)
|
||||
if ceiling <= engineCacheRAMDefault {
|
||||
return 0, fmt.Sprintf("RAM libre trop juste (plafond %d Mio)", ceiling)
|
||||
}
|
||||
if need <= engineCacheRAMDefault {
|
||||
return 0, fmt.Sprintf("le défaut suffit (besoin estimé %d Mio)", need)
|
||||
}
|
||||
return min(need, ceiling), fmt.Sprintf("besoin estimé %d Mio, plafond %d Mio", need, ceiling)
|
||||
}
|
||||
|
||||
// cacheRAMSetting lit CACHE_RAM : auto (vide ou « auto »), ou un nombre de Mio
|
||||
// passé tel quel (-1 = sans limite, 0 = cache coupé). ok=false : valeur
|
||||
// illisible.
|
||||
func cacheRAMSetting(cfg map[string]string) (manual string, auto, ok bool) {
|
||||
v := strings.TrimSpace(cfg["CACHE_RAM"])
|
||||
if v == "" || strings.EqualFold(v, "auto") {
|
||||
return "", true, true
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= -1 {
|
||||
return strconv.Itoa(n), false, true
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// cacheRAMArgs : le --cache-ram que Loki ajoute, et ce qu'il en dit.
|
||||
func cacheRAMArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
|
||||
manual, auto, ok := cacheRAMSetting(cfg)
|
||||
if !ok {
|
||||
return nil, []string{"CACHE_RAM=" + cfg["CACHE_RAM"] + " illisible (un nombre de Mio, -1, 0 ou auto) : ignoré"}
|
||||
}
|
||||
if !strings.Contains(si.Help, "--cache-ram") {
|
||||
if !auto {
|
||||
notes = append(notes, "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré")
|
||||
}
|
||||
return nil, notes
|
||||
}
|
||||
if hasAnyFlag(extra, "-cram", "--cache-ram") {
|
||||
return nil, nil
|
||||
}
|
||||
if si.ArgEnv["LLAMA_ARG_CACHE_RAM"] != "" {
|
||||
if !auto {
|
||||
notes = append(notes, "LLAMA_ARG_CACHE_RAM est posée : CACHE_RAM ignoré")
|
||||
}
|
||||
return nil, notes
|
||||
}
|
||||
if !auto {
|
||||
return []string{"--cache-ram", manual}, nil
|
||||
}
|
||||
n, why := cacheRAMAuto(cfg, extra, si)
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []string{"--cache-ram", strconv.FormatInt(n, 10)},
|
||||
[]string{fmt.Sprintf("cache de prompts : --cache-ram %d (RAM hôte, copie exacte de l'état, jamais la VRAM ; %s). "+
|
||||
"CACHE_RAM=<Mio> pour fixer, 0 pour couper.", n, why)}
|
||||
}
|
||||
|
||||
// cacheRAMOff : le cache de prompts est-il coupé (0) par l'une des trois
|
||||
// sources ? Sans lui, effacer un slot ne protège rien.
|
||||
func cacheRAMOff(cfg map[string]string, extra []string, argEnv map[string]string) bool {
|
||||
v := flagValue(extra, "-cram", "--cache-ram")
|
||||
if v == "" {
|
||||
v = argEnv["LLAMA_ARG_CACHE_RAM"]
|
||||
}
|
||||
if v == "" {
|
||||
v, _, _ = cacheRAMSetting(cfg)
|
||||
}
|
||||
return strings.TrimSpace(v) == "0"
|
||||
}
|
||||
|
||||
// cacheIsolationOn : CACHE_ISOLATE=off coupe l'effacement du slot après les
|
||||
// travaux annexes (et donc --slot-save-path), sans toucher au code.
|
||||
func cacheIsolationOn(cfg map[string]string) bool {
|
||||
return !strings.EqualFold(strings.TrimSpace(cfg["CACHE_ISOLATE"]), "off")
|
||||
}
|
||||
|
||||
// loopbackHost : le moteur n'écoute que sur la machine.
|
||||
func loopbackHost(h string) bool {
|
||||
switch strings.Trim(strings.TrimSpace(h), "[]") {
|
||||
case "127.0.0.1", "::1", "localhost":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// slotSaveArgs : --slot-save-path, sans lequel llama-server refuse toute action
|
||||
// sur /slots — l'effacement compris. Mais le drapeau ouvre aussi save et
|
||||
// restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On
|
||||
// ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé
|
||||
// d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et
|
||||
// que l'isolation servira : cache actif, CACHE_ISOLATE pas à off. Les proxys de
|
||||
// Loki refusent de leur côté tout /slots qui n'est pas une lecture.
|
||||
func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string {
|
||||
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") ||
|
||||
hasAnyFlag(extra, "--slot-save-path") || !cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv) {
|
||||
return nil
|
||||
}
|
||||
host := flagValue(extra, "--host")
|
||||
if host == "" {
|
||||
host = cfg["HOST"]
|
||||
}
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
if !loopbackHost(host) && si.APIKey == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{"--slot-save-path", si.SlotDir}
|
||||
}
|
||||
|
||||
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : Loki n'y sauve jamais
|
||||
// rien, un fichier présent ne peut venir que d'un appel qui n'aurait pas dû
|
||||
// passer. Vide = dossier indisponible, le drapeau ne sera pas posé.
|
||||
func prepareSlotDir(home string) string {
|
||||
dir := filepath.Join(home, "slots")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return ""
|
||||
}
|
||||
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
|
||||
return ""
|
||||
}
|
||||
if entries, err := os.ReadDir(dir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.Type().IsRegular() {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// cacheRAMPreview : la taille que le moteur prendra pour ce preset, pour
|
||||
// l'éditeur. 0 = défaut du moteur (8192 Mio). Même calcul qu'au lancement, mais
|
||||
// sans l'aide du moteur : l'aperçu dit ce que Loki demanderait.
|
||||
func cacheRAMPreview(content string) (mib int64, why string) {
|
||||
cfg := parseEnv(content)
|
||||
if isExternalConfig(cfg) {
|
||||
return 0, "preset externe"
|
||||
}
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
if v := flagValue(extra, "-cram", "--cache-ram"); v != "" {
|
||||
n, _ := strconv.ParseInt(v, 10, 64)
|
||||
return n, "fixé par EXTRA_ARGS"
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("LLAMA_ARG_CACHE_RAM")); v != "" {
|
||||
n, _ := strconv.ParseInt(v, 10, 64)
|
||||
return n, "fixé par LLAMA_ARG_CACHE_RAM"
|
||||
}
|
||||
manual, auto, ok := cacheRAMSetting(cfg)
|
||||
if !ok {
|
||||
return 0, "CACHE_RAM illisible"
|
||||
}
|
||||
if !auto {
|
||||
n, _ := strconv.ParseInt(manual, 10, 64)
|
||||
return n, "fixé par CACHE_RAM"
|
||||
}
|
||||
si := serveSysInfo{ArgEnv: map[string]string{}}
|
||||
for _, k := range append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...) {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
|
||||
if p, err := resolveServeModelPath(m); err == nil {
|
||||
si.Model = p
|
||||
}
|
||||
}
|
||||
if si.Model == "" {
|
||||
return 0, "modèle introuvable"
|
||||
}
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
return cacheRAMAuto(cfg, extra, si)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
const helpCacheRAM = helpRecent + `-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)
|
||||
--slot-save-path PATH path to save slot kv cache (default: disabled)
|
||||
`
|
||||
|
||||
// denseGPU : un 27B dense de 17 Go sur deux cartes de 24 Go, machine de 64 Go.
|
||||
// État : 64 couches × 4 têtes KV × (256+256) × 2 octets = 0,25 Mio par jeton.
|
||||
func denseGPU() serveSysInfo {
|
||||
return serveSysInfo{
|
||||
Help: helpCacheRAM,
|
||||
Model: "/models/dense.gguf",
|
||||
GGUF: &GGUFInfo{Arch: "qwen3", BlockCount: 64, HeadCountKV: 4, KeyLen: 256, ValLen: 256,
|
||||
ContextLength: 262144},
|
||||
ModelBytes: 17 << 30,
|
||||
RAMMiB: 65536, RAMAvailMiB: 60000,
|
||||
VRAMMiB: 2 * 24576,
|
||||
ArgEnv: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRAMArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
notes int
|
||||
}{
|
||||
{name: "dense sur GPU, 64 Go, 65k : besoin 40 Gio, plafonné à 30 % de la RAM",
|
||||
cfg: map[string]string{"CTX": "65536"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "contexte court : le défaut du moteur suffit",
|
||||
cfg: map[string]string{"CTX": "8192"}},
|
||||
{name: "cache q8_0 : l'état pèse deux fois moins, besoin sous le plafond",
|
||||
cfg: map[string]string{"CTX": "32768", "KV_TYPE": "q8_0"}, want: []string{"--cache-ram", "10880"}, notes: 1},
|
||||
{name: "même contexte en f16 : plafonné",
|
||||
cfg: map[string]string{"CTX": "32768"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "-c d'EXTRA_ARGS l'emporte sur CTX",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-c", "8192"}},
|
||||
{name: "MoE, experts sur CPU par -ot : jamais touché (pas de 4096)",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "exps=CPU"}},
|
||||
{name: "-ot mixte avec un élément sur CPU",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "a=CUDA0,b=CPU"}},
|
||||
{name: "-ot vers une autre carte seulement : tout reste en VRAM",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "blk\\.6.*=CUDA1"},
|
||||
want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "--n-cpu-moe",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"--n-cpu-moe", "30"}},
|
||||
{name: "LLAMA_ARG_N_CPU_MOE",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_N_CPU_MOE"] = "20" }},
|
||||
{name: "cache KV sur CPU",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-nkvo"}},
|
||||
{name: "NGL plus petit que le modèle",
|
||||
cfg: map[string]string{"CTX": "65536", "NGL": "40"}},
|
||||
{name: "NGL=999 : toutes les couches",
|
||||
cfg: map[string]string{"CTX": "65536", "NGL": "999"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "modèle trop gros pour la VRAM",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ModelBytes = 40 << 30 }},
|
||||
{name: "pas de VRAM connue (macOS, AMD, sans nvidia-smi)",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.VRAMMiB = 0 }},
|
||||
{name: "GGUF illisible",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||||
{name: "RAM libre trop juste",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.RAMAvailMiB = 12000 }},
|
||||
{name: "CACHE_RAM fixé : passé tel quel",
|
||||
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, want: []string{"--cache-ram", "12000"}},
|
||||
{name: "CACHE_RAM=-1 : sans limite, passé tel quel",
|
||||
cfg: map[string]string{"CACHE_RAM": "-1"}, want: []string{"--cache-ram", "-1"}},
|
||||
{name: "CACHE_RAM=0 : cache coupé",
|
||||
cfg: map[string]string{"CACHE_RAM": "0"}, want: []string{"--cache-ram", "0"}},
|
||||
{name: "CACHE_RAM=auto",
|
||||
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "auto"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "CACHE_RAM illisible : ignoré, et dit",
|
||||
cfg: map[string]string{"CACHE_RAM": "beaucoup"}, notes: 1},
|
||||
{name: "-cram d'EXTRA_ARGS : Loki se tait",
|
||||
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, extra: []string{"-cram", "2048"}},
|
||||
{name: "LLAMA_ARG_CACHE_RAM : la ligne de commande l'écraserait, Loki se tait",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }},
|
||||
{name: "LLAMA_ARG_CACHE_RAM et CACHE_RAM : la variable gagne, et on le dit",
|
||||
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }, notes: 1},
|
||||
{name: "moteur sans --cache-ram : rien, même fixé",
|
||||
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.Help = helpRecent }, notes: 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := denseGPU()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes := cacheRAMArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("args = %v, attendu %v (notes %v)", got, c.want, notes)
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Fatalf("notes = %v, attendu %d", notes, c.notes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Hybride (Qwen3.5) : 16 couches d'attention sur 64, état récurrent des 48
|
||||
// autres, compté avec ses points de reprise.
|
||||
func TestCacheRAMAutoHybride(t *testing.T) {
|
||||
si := denseGPU()
|
||||
layers := make([]int, 64)
|
||||
for i := range layers {
|
||||
if (i+1)%4 == 0 {
|
||||
layers[i] = 4
|
||||
}
|
||||
}
|
||||
si.GGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 64, HeadCountKV: 4, HeadCountKVLayers: layers,
|
||||
KeyLen: 256, ValLen: 256, FullAttnInterval: 4, Hybrid: true,
|
||||
SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16}
|
||||
perTok, rec := ggufStateBytes(*si.GGUF, "", "")
|
||||
if perTok != 65536 || rec != 156893184 {
|
||||
t.Fatalf("état = %v o/jeton, %d o récurrents", perTok, rec)
|
||||
}
|
||||
// 4 Gio de KV + 10 états récurrents (9 points de reprise + l'état courant),
|
||||
// × 2,5.
|
||||
n, why := cacheRAMAuto(map[string]string{"CTX": "65536"}, nil, si)
|
||||
if n != 13981 {
|
||||
t.Fatalf("cacheRAMAuto = %d (%s), attendu 13981", n, why)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans key_length, llama.cpp prend n_embd / n_head : nous aussi.
|
||||
func TestGGUFStateBytesReplis(t *testing.T) {
|
||||
g := GGUFInfo{BlockCount: 2, HeadCountKV: 2, EmbeddingLength: 1024, HeadCount: 8}
|
||||
if p, _ := ggufStateBytes(g, "", ""); p != 2*2*(128+128)*2 {
|
||||
t.Fatalf("repli n_embd/n_head : %v", p)
|
||||
}
|
||||
if p, _ := ggufStateBytes(GGUFInfo{BlockCount: 2, HeadCountKV: 2}, "", ""); p != 0 {
|
||||
t.Fatalf("dimensions inconnues : %v, attendu 0", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVRAMFromSMI(t *testing.T) {
|
||||
const out = "0, 24576\n1, 16384\n"
|
||||
cases := []struct {
|
||||
cvd string
|
||||
pci bool
|
||||
want int64
|
||||
}{
|
||||
{"", false, 40960},
|
||||
{"1", true, 16384},
|
||||
{"0,1", true, 40960},
|
||||
{"1", false, 16384}, // ordre inconnu : la plus petite carte
|
||||
{"0", false, 16384},
|
||||
{"GPU-1234", true, 0},
|
||||
{"2", true, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := vramFromSMI(out, c.cvd, c.pci); got != c.want {
|
||||
t.Errorf("vramFromSMI(%q, %v) = %d, attendu %d", c.cvd, c.pci, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := vramFromSMI("[N/A]\n", "", false); got != 0 {
|
||||
t.Errorf("sortie illisible : %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgroupMemMiB(t *testing.T) {
|
||||
v2 := fstest.MapFS{
|
||||
"sys/fs/cgroup/memory.max": {Data: []byte("17179869184\n")},
|
||||
"sys/fs/cgroup/memory.current": {Data: []byte("4294967296\n")},
|
||||
}
|
||||
if l, c := cgroupMemMiB(v2); l != 16384 || c != 4096 {
|
||||
t.Fatalf("v2 = %d/%d", l, c)
|
||||
}
|
||||
if l, _ := cgroupMemMiB(fstest.MapFS{"sys/fs/cgroup/memory.max": {Data: []byte("max\n")}}); l != 0 {
|
||||
t.Fatalf("v2 sans limite = %d", l)
|
||||
}
|
||||
v1 := fstest.MapFS{"sys/fs/cgroup/memory/memory.limit_in_bytes": {Data: []byte("9223372036854771712\n")}}
|
||||
if l, _ := cgroupMemMiB(v1); l != 0 {
|
||||
t.Fatalf("v1 sans limite = %d", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotSaveArgs(t *testing.T) {
|
||||
want := []string{"--slot-save-path", "/data/slots"}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
}{
|
||||
{name: "boucle locale", cfg: map[string]string{"HOST": "127.0.0.1"}, want: want},
|
||||
{name: "--host ::1 dans EXTRA_ARGS", extra: []string{"--host", "::1"}, want: want},
|
||||
{name: "0.0.0.0 sans clé : ouvert à tous, pas de drapeau", cfg: map[string]string{}},
|
||||
{name: "0.0.0.0 avec clé d'API", cfg: map[string]string{"HOST": "0.0.0.0"},
|
||||
si: func(s *serveSysInfo) { s.APIKey = "k" }, want: want},
|
||||
{name: "EXTRA_ARGS le pose déjà", cfg: map[string]string{"HOST": "127.0.0.1"},
|
||||
extra: []string{"--slot-save-path", "/ailleurs"}},
|
||||
{name: "CACHE_ISOLATE=off", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_ISOLATE": "off"}},
|
||||
{name: "cache coupé : rien à isoler", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}},
|
||||
{name: "cache coupé par EXTRA_ARGS", cfg: map[string]string{"HOST": "127.0.0.1"}, extra: []string{"-cram", "0"}},
|
||||
{name: "moteur sans le drapeau", cfg: map[string]string{"HOST": "127.0.0.1"},
|
||||
si: func(s *serveSysInfo) { s.Help = helpRecent }},
|
||||
{name: "dossier indisponible", cfg: map[string]string{"HOST": "127.0.0.1"},
|
||||
si: func(s *serveSysInfo) { s.SlotDir = "" }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
if got := slotSaveArgs(c.cfg, c.extra, si); !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("slotSaveArgs = %v, attendu %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Place dans la ligne complète : après --api-key, avant EXTRA_ARGS qui garde le
|
||||
// dernier mot. Sans aide qui les connaisse, la ligne historique est intacte
|
||||
// (TestBuildServeArgs).
|
||||
func TestBuildServeArgsCacheRAM(t *testing.T) {
|
||||
si := denseGPU()
|
||||
si.APIKey, si.SlotDir = "k", "/data/slots"
|
||||
args, _, _ := buildServeArgs(map[string]string{"CTX": "65536", "NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", si)
|
||||
got := strings.Join(args, " ")
|
||||
if !strings.HasSuffix(got, "--api-key k --cache-ram 19660 --slot-save-path /data/slots --jinja") {
|
||||
t.Fatalf("ligne = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSlotDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dir := prepareSlotDir(home)
|
||||
if dir != filepath.Join(home, "slots") {
|
||||
t.Fatalf("dossier = %q", dir)
|
||||
}
|
||||
stray := filepath.Join(dir, "slot0.bin")
|
||||
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prepareSlotDir(home) == "" {
|
||||
t.Fatal("second appel")
|
||||
}
|
||||
if _, err := os.Stat(stray); !os.IsNotExist(err) {
|
||||
t.Fatal("un fichier laissé dans slots/ doit être purgé au lancement")
|
||||
}
|
||||
// Un fichier à la place du dossier : pas de drapeau, plutôt qu'un moteur
|
||||
// qui refuse de démarrer.
|
||||
bad := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := prepareSlotDir(bad); got != "" {
|
||||
t.Fatalf("slots est un fichier : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Points de reprise des modèles hybrides (--ctx-checkpoints,
|
||||
// --checkpoint-min-step) et conservation du raisonnement (--reasoning-preserve).
|
||||
//
|
||||
// Un modèle hybride (couches récurrentes : Qwen3.5/3.6, Qwen3-Next…) ne sait pas
|
||||
// rembobiner son état récurrent comme un cache KV : llama-server en garde des
|
||||
// copies exactes, les points de reprise, et repart du plus proche quand la
|
||||
// conversation diverge ou revient du cache de prompts. Avant b10864 (PR #28302),
|
||||
// le moteur effaçait un point trop proche du précédent (moins de 8192 jetons)
|
||||
// MÊME quand la liste avait de la place : la reprise retombait jusqu'à ~8 k
|
||||
// jetons en arrière, recalculés à chaque tour d'outil. Rien de ce que voit le
|
||||
// modèle ne change ici — un point de reprise est une copie octet pour octet de
|
||||
// l'état — seul le temps de prefill, et la RAM hôte que ces copies occupent.
|
||||
//
|
||||
// Loki ne touche à rien sans savoir : le build n'est cru que pour un moteur
|
||||
// officiel (voir engineBuildTrusted), le drapeau n'est posé que si l'aide le
|
||||
// connaît, et l'utilisateur garde le dernier mot (clés du preset, EXTRA_ARGS,
|
||||
// LLAMA_ARG_*). Tout ce qui décide est pur ; cmdServe fait les lectures.
|
||||
|
||||
const (
|
||||
// engineMinRecommended : premier build où l'espacement minimal n'évince un
|
||||
// point de reprise que lorsque la liste est pleine (PR #28302, 5d806aa).
|
||||
engineMinRecommended = 10864
|
||||
// engineBuildFloor : en dessous, le numéro ne prouve rien. Un llama.cpp
|
||||
// compilé depuis un clone superficiel (--depth=1) annonce « build 1 », un
|
||||
// fork numérote à sa façon : traiter ça comme un moteur ancien poserait un
|
||||
// avis faux et un drapeau inutile sur un moteur récent.
|
||||
engineBuildFloor = 5000
|
||||
// engineCkptDefault : n_ctx_checkpoints par défaut de llama.cpp (common.h).
|
||||
engineCkptDefault = 32
|
||||
// ckptAutoMinStep : espacement posé d'office sur un moteur ancien. Plus serré
|
||||
// que les 8192 du moteur, il garde des points qu'il aurait jetés ; jamais 0,
|
||||
// qui laisserait l'éviction FIFO jeter le point du début de la conversation
|
||||
// dans une longue boucle d'outils.
|
||||
ckptAutoMinStep = 2048
|
||||
// ckptFallbackMiB : poids d'un point de reprise quand le GGUF ne permet pas de
|
||||
// le calculer (70 à 200 Mio mesurés sur les hybrides Qwen) — on prend le haut.
|
||||
ckptFallbackMiB = 200
|
||||
)
|
||||
|
||||
// ckptArgEnv : les variables qui règlent la même chose sans drapeau. llama.cpp
|
||||
// les lit avant la ligne de commande : un drapeau de Loki les écraserait.
|
||||
var ckptArgEnv = []string{"LLAMA_ARG_CTX_CHECKPOINTS", "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT",
|
||||
"LLAMA_ARG_REASONING_PRESERVE", "LLAMA_ARG_CHAT_TEMPLATE_KWARGS"}
|
||||
|
||||
// probeCkptEnv complète ArgEnv avec ckptArgEnv.
|
||||
func probeCkptEnv(si *serveSysInfo) {
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range ckptArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hybridArchs : architectures hybrides connues, au cas où le GGUF ne porterait
|
||||
// pas de clé ssm.* lisible.
|
||||
var hybridArchs = map[string]bool{"qwen35": true, "qwen35moe": true, "qwen3next": true, "qwen4exp": true}
|
||||
|
||||
// ggufHybrid : le modèle a-t-il des couches récurrentes ? Jamais deviné d'après
|
||||
// le nom du fichier : sans métadonnées, on répond non.
|
||||
func ggufHybrid(g *GGUFInfo) bool {
|
||||
return g != nil && (g.Hybrid || hybridArchs[strings.ToLower(g.Arch)])
|
||||
}
|
||||
|
||||
// engineSource classe un moteur comme le panneau Moteur : celui de l'image,
|
||||
// un moteur téléchargé par Loki depuis l'image officielle, le précompilé des
|
||||
// releases officielles, ou tout le reste (« custom » : compilé, fork, chemin
|
||||
// à la main).
|
||||
func engineSource(bin string) string {
|
||||
imageBin := providedEngineBin()
|
||||
switch {
|
||||
case imageBin != "" && samePath(bin, imageBin):
|
||||
return "image"
|
||||
case engineOwns(bin):
|
||||
return "downloaded"
|
||||
case prebuiltOwns(bin):
|
||||
return "prebuilt"
|
||||
}
|
||||
return "custom"
|
||||
}
|
||||
|
||||
// engineBuildTrust : le build n'est cru que pour un moteur publié par ggml-org,
|
||||
// dont la CI numérote sur l'historique complet, et s'il est vraisemblable. 0 =
|
||||
// inconnu : ni avis, ni drapeau automatique.
|
||||
func engineBuildTrust(source string, build int) int {
|
||||
if source == "custom" || build < engineBuildFloor {
|
||||
return 0
|
||||
}
|
||||
return build
|
||||
}
|
||||
|
||||
// engineBuildTrusted : build du moteur, ou 0 s'il n'est pas digne de confiance.
|
||||
// Le « --version » est gardé par binaire, comme l'aide (binHelp), et la clé
|
||||
// porte la taille et la date du fichier : un moteur recompilé au même chemin
|
||||
// est relu.
|
||||
func engineBuildTrusted(bin string) int {
|
||||
src := engineSource(bin)
|
||||
if src == "custom" {
|
||||
return 0 // pas la peine de lancer le binaire
|
||||
}
|
||||
return engineBuildTrust(src, engineBuildCached(bin))
|
||||
}
|
||||
|
||||
var (
|
||||
buildMu sync.Mutex
|
||||
buildCache = map[string]int{}
|
||||
)
|
||||
|
||||
func engineBuildCached(bin string) int {
|
||||
st, err := os.Stat(bin)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d|%d", bin, st.Size(), st.ModTime().UnixNano())
|
||||
buildMu.Lock()
|
||||
defer buildMu.Unlock()
|
||||
if b, ok := buildCache[key]; ok {
|
||||
return b
|
||||
}
|
||||
b, _ := engineBuildOf(bin)
|
||||
buildCache[key] = b
|
||||
return b
|
||||
}
|
||||
|
||||
// engineBuildNotice : l'avis du panneau Moteur et du journal de lancement pour
|
||||
// un build trop ancien. Vide = rien à dire (à jour, ou build inconnu).
|
||||
func engineBuildNotice(build int) string {
|
||||
if build <= 0 || build >= engineMinRecommended {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("moteur b%d : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont "+
|
||||
"évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b%d.",
|
||||
build, engineMinRecommended)
|
||||
}
|
||||
|
||||
// ckptCount : nombre de points de reprise par slot que le moteur gardera
|
||||
// (CTX_CHECKPOINTS valide, sinon EXTRA_ARGS, sinon la variable, sinon le
|
||||
// défaut). -1 = illisible ; un CTX_CHECKPOINTS illisible est ignoré (voir
|
||||
// ckptArgs), le moteur garde alors son défaut.
|
||||
func ckptCount(cfg map[string]string, extra []string, argEnv map[string]string) int {
|
||||
v := flagValue(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints")
|
||||
if v == "" {
|
||||
v = argEnv["LLAMA_ARG_CTX_CHECKPOINTS"]
|
||||
}
|
||||
if v == "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX_CHECKPOINTS"])); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
return engineCkptDefault
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n < 0 {
|
||||
return -1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ckptSlots : nombre de slots que le moteur ouvrira, chacun avec sa propre liste
|
||||
// de points de reprise. --parallel d'EXTRA_ARGS, sinon PARALLEL, sinon 1 (Loki
|
||||
// pose toujours --parallel, voir buildServeArgs). Illisible ou « auto » (-1) :
|
||||
// 4, ce que choisissent les moteurs récents — on compte large.
|
||||
func ckptSlots(cfg map[string]string, extra []string) int {
|
||||
v := flagValue(extra, "-np", "--parallel")
|
||||
if v == "" {
|
||||
v = strings.TrimSpace(cfg["PARALLEL"])
|
||||
}
|
||||
if v == "" {
|
||||
return 1
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
// ckptMiB : poids d'un point de reprise d'un hybride (état récurrent en f32).
|
||||
func ckptMiB(g *GGUFInfo) int64 {
|
||||
if g != nil {
|
||||
if _, rec := ggufStateBytes(*g, "", ""); rec > 0 {
|
||||
return (rec + (1 << 20) - 1) >> 20
|
||||
}
|
||||
}
|
||||
return ckptFallbackMiB
|
||||
}
|
||||
|
||||
// ramTight : le modèle occupe plus de 70 % de la RAM (mmap d'un gros MoE dont
|
||||
// les experts vivent dans le cache de pages). Chaque Mio de points de reprise
|
||||
// y est pris aux poids : plus de lectures disque, décodage plus lent.
|
||||
func ramTight(si serveSysInfo) bool {
|
||||
return si.RAMMiB > 0 && si.ModelBytes > 0 && float64(si.ModelBytes) > 0.7*float64(si.RAMMiB)*(1<<20)
|
||||
}
|
||||
|
||||
// ckptArgs : --ctx-checkpoints et --checkpoint-min-step que Loki ajoute, et ce
|
||||
// qu'il en dit.
|
||||
//
|
||||
// CTX_CHECKPOINTS=<n> → --ctx-checkpoints n (opt-in ; jamais de défaut relevé)
|
||||
// CKPT_MIN_STEP=<n> → --checkpoint-min-step n (n > 0)
|
||||
// clé absente → --checkpoint-min-step 2048 seulement sur un hybride,
|
||||
// moteur officiel < b10864, RAM non serrée
|
||||
//
|
||||
// Chaque drapeau a sa propre garde : un --ctx-checkpoints écrit à la main ne
|
||||
// coupe pas l'atténuation de l'espacement, qui est une autre question.
|
||||
func ckptArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
|
||||
hybrid := ggufHybrid(si.GGUF)
|
||||
|
||||
if v := strings.TrimSpace(cfg["CTX_CHECKPOINTS"]); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
switch {
|
||||
case err != nil || n < 0:
|
||||
notes = append(notes, "CTX_CHECKPOINTS="+v+" illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré")
|
||||
case !strings.Contains(si.Help, "--ctx-checkpoints"):
|
||||
notes = append(notes, "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré")
|
||||
case hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints"):
|
||||
case si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] != "":
|
||||
notes = append(notes, "LLAMA_ARG_CTX_CHECKPOINTS est posée : CTX_CHECKPOINTS ignoré")
|
||||
default:
|
||||
args = append(args, "--ctx-checkpoints", strconv.Itoa(n))
|
||||
}
|
||||
} else if hybrid && ramTight(si) && strings.Contains(si.Help, "--ctx-checkpoints") &&
|
||||
!hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints") && si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] == "" {
|
||||
// Un conseil, pas un réglage : moins de points de reprise, c'est plus de
|
||||
// prefill, et seul l'utilisateur peut mesurer lequel des deux coûte le plus.
|
||||
notes = append(notes, fmt.Sprintf("modèle hybride de %d Gio pour %d Gio de RAM : jusqu'à %d points de reprise "+
|
||||
"(~%d Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.",
|
||||
si.ModelBytes>>30, si.RAMMiB>>10, engineCkptDefault, ckptMiB(si.GGUF)))
|
||||
}
|
||||
|
||||
stepUser := hasAnyFlag(extra, "-cms", "--checkpoint-min-step") || si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != ""
|
||||
if v := strings.TrimSpace(cfg["CKPT_MIN_STEP"]); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
switch {
|
||||
case err != nil || n <= 0:
|
||||
// 0 = « aucun minimum » pour le moteur : la liste se remplit de points
|
||||
// serrés et l'éviction FIFO finit par jeter celui du début de la
|
||||
// conversation. On ne le pose pas, sans se taire pour autant.
|
||||
notes = append(notes, "CKPT_MIN_STEP="+v+" refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter "+
|
||||
"le point de reprise du début de la conversation) : défaut du moteur")
|
||||
case !strings.Contains(si.Help, "--checkpoint-min-step"):
|
||||
notes = append(notes, "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré")
|
||||
case hasAnyFlag(extra, "-cms", "--checkpoint-min-step"):
|
||||
case si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != "":
|
||||
notes = append(notes, "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT est posée : CKPT_MIN_STEP ignoré")
|
||||
default:
|
||||
args = append(args, "--checkpoint-min-step", strconv.Itoa(n))
|
||||
}
|
||||
return args, notes
|
||||
}
|
||||
|
||||
notice := engineBuildNotice(si.EngineBuild)
|
||||
if !hybrid || notice == "" {
|
||||
return args, notes
|
||||
}
|
||||
n := ckptCount(cfg, extra, si.ArgEnv)
|
||||
switch {
|
||||
case stepUser || !strings.Contains(si.Help, "--checkpoint-min-step"):
|
||||
return args, append(notes, notice)
|
||||
case n <= 0:
|
||||
return args, append(notes, notice) // points de reprise coupés : l'espacement ne sert à rien
|
||||
case si.RAMMiB <= 0 || si.ModelBytes <= 0 || ramTight(si):
|
||||
return args, append(notes, notice+" Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle "+
|
||||
"(CKPT_MIN_STEP=2048 pour l'imposer).")
|
||||
}
|
||||
// La liste est PAR SLOT : avec PARALLEL=4, quatre listes se remplissent.
|
||||
total := n * ckptSlots(cfg, extra)
|
||||
worst := int64(total) * ckptMiB(si.GGUF)
|
||||
if si.RAMAvailMiB > 0 && worst > si.RAMAvailMiB/4 {
|
||||
return args, append(notes, fmt.Sprintf("%s Pas d'espacement resserré d'office : %d points de reprise (~%d Mio) "+
|
||||
"dépasseraient le quart de la RAM libre (CKPT_MIN_STEP=2048 pour l'imposer).", notice, total, worst))
|
||||
}
|
||||
args = append(args, "--checkpoint-min-step", strconv.Itoa(ckptAutoMinStep))
|
||||
return args, append(notes, fmt.Sprintf("%s → --checkpoint-min-step %d : points de reprise exacts plus rapprochés, "+
|
||||
"~%d Mio de RAM hôte au plus. CKPT_MIN_STEP=<jetons> pour fixer.", notice, ckptAutoMinStep, worst))
|
||||
}
|
||||
|
||||
// reasoningPreserveArgs : REASONING_PRESERVE=on|off → --reasoning-preserve /
|
||||
// --no-reasoning-preserve, seulement si la clé est posée.
|
||||
//
|
||||
// Le compromis, qui explique pourquoi Loki ne choisit pas : depuis b10763 le
|
||||
// moteur active preserve_reasoning par défaut, et le gabarit garde alors la
|
||||
// réflexion de TOUS les tours passés. Mais Loki ne renvoie pas le raisonnement
|
||||
// des tours passés : un gabarit qui ne conserve rien de lui-même (Qwen3.6) rend
|
||||
// alors des blocs de réflexion vides, là où « off » rend l'historique des
|
||||
// anciens moteurs. À l'inverse, un gabarit qui conserve déjà de lui-même
|
||||
// (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est neutre
|
||||
// pour tous les modèles : vide = défaut du moteur, inchangé.
|
||||
func reasoningPreserveArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
|
||||
v := strings.ToLower(strings.TrimSpace(cfg["REASONING_PRESERVE"]))
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
flag := ""
|
||||
switch v {
|
||||
case "on":
|
||||
flag = "--reasoning-preserve"
|
||||
case "off":
|
||||
flag = "--no-reasoning-preserve"
|
||||
default:
|
||||
return nil, []string{"REASONING_PRESERVE=" + cfg["REASONING_PRESERVE"] + " illisible (on ou off) : ignoré"}
|
||||
}
|
||||
switch {
|
||||
case !strings.Contains(si.Help, "--no-reasoning-preserve"):
|
||||
return nil, []string{"ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré"}
|
||||
case hasAnyFlag(extra, "--reasoning-preserve", "--no-reasoning-preserve"):
|
||||
return nil, nil
|
||||
case strings.Contains(strings.Join(flagValues(extra, "--chat-template-kwargs"), " "), "preserve_reasoning"):
|
||||
return nil, []string{"--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
|
||||
case si.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] != "":
|
||||
return nil, []string{"LLAMA_ARG_REASONING_PRESERVE est posée : REASONING_PRESERVE ignoré"}
|
||||
case strings.Contains(si.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"], "preserve_reasoning"):
|
||||
return nil, []string{"LLAMA_ARG_CHAT_TEMPLATE_KWARGS fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
|
||||
}
|
||||
return []string{flag}, nil
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const helpCkpt = helpRecent + `-ctxcp, --ctx-checkpoints, --swa-checkpoints N
|
||||
max number of context checkpoints to create per slot (default: 32)
|
||||
-cms, --checkpoint-min-step N minimum spacing between context checkpoints in tokens (default: 8192, 0 = no minimum)
|
||||
--reasoning-preserve, --no-reasoning-preserve
|
||||
preserve reasoning trace in the full history, not just the last assistant message (default: enabled)
|
||||
`
|
||||
|
||||
// hybridOld : un Qwen3.6 27B hybride de 17 Go sur un moteur officiel b10678,
|
||||
// machine de 64 Go. État récurrent : 48 couches × (3 × 10240 + 128 × 6144) × 4
|
||||
// octets ≈ 150 Mio par point de reprise.
|
||||
func hybridOld() serveSysInfo {
|
||||
return serveSysInfo{
|
||||
Help: helpCkpt,
|
||||
Model: "/models/Qwen3.6-27B.gguf",
|
||||
GGUF: &GGUFInfo{Arch: "qwen35", Hybrid: true, BlockCount: 64, FullAttnInterval: 4, HeadCountKV: 4,
|
||||
KeyLen: 256, ValLen: 256, SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16},
|
||||
ModelBytes: 17 << 30,
|
||||
RAMMiB: 65536,
|
||||
RAMAvailMiB: 50000,
|
||||
ArgEnv: map[string]string{},
|
||||
EngineBuild: 10678,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCkptArgs(t *testing.T) {
|
||||
cms := []string{"--checkpoint-min-step", "2048"}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
notes int
|
||||
}{
|
||||
{name: "hybride, moteur b10678 : espacement resserré d'office", want: cms, notes: 1},
|
||||
{name: "hybride, moteur b11351 : rien", si: func(s *serveSysInfo) { s.EngineBuild = 11351 }},
|
||||
{name: "hybride, build pile au correctif : rien", si: func(s *serveSysInfo) { s.EngineBuild = engineMinRecommended }},
|
||||
{name: "build inconnu (compilé, clone superficiel, fork) : ni avis ni drapeau",
|
||||
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
|
||||
{name: "modèle dense sur moteur ancien : rien",
|
||||
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 64} }},
|
||||
{name: "métadonnées illisibles : jamais deviné au nom du fichier",
|
||||
si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||||
{name: "arch hybride connue sans clé ssm.*", want: cms, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3next"} }},
|
||||
{name: "CKPT_MIN_STEP=4096 passé tel quel, quel que soit le build",
|
||||
cfg: map[string]string{"CKPT_MIN_STEP": "4096"}, want: []string{"--checkpoint-min-step", "4096"},
|
||||
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
|
||||
{name: "CKPT_MIN_STEP=0 refusé, défaut du moteur, et on le dit",
|
||||
cfg: map[string]string{"CKPT_MIN_STEP": "0"}, notes: 1},
|
||||
{name: "-cms d'EXTRA_ARGS : Loki se tait sur l'espacement",
|
||||
extra: []string{"-cms", "1024"}, notes: 1},
|
||||
{name: "--checkpoint-min-step=1024 d'EXTRA_ARGS", cfg: map[string]string{"CKPT_MIN_STEP": "4096"},
|
||||
extra: []string{"--checkpoint-min-step=1024"}},
|
||||
{name: "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT posée : pas d'espacement d'office", notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] = "4096" }},
|
||||
{name: "--ctx-checkpoints d'EXTRA_ARGS ne coupe pas l'espacement", extra: []string{"--ctx-checkpoints", "8"},
|
||||
want: cms, notes: 1},
|
||||
{name: "aide sans -cms : pas de drapeau, l'avis reste", notes: 1,
|
||||
si: func(s *serveSysInfo) { s.Help = helpRecent }},
|
||||
{name: "points de reprise coupés : l'espacement ne sert à rien", cfg: map[string]string{"CTX_CHECKPOINTS": "0"},
|
||||
want: []string{"--ctx-checkpoints", "0"}, notes: 1},
|
||||
{name: "MoE mmappé de 82 Go sur 64 Go : conseil, pas d'espacement d'office", notes: 2,
|
||||
si: func(s *serveSysInfo) { s.ModelBytes = 82 << 30 }},
|
||||
{name: "RAM inconnue : pas d'espacement d'office", notes: 1,
|
||||
si: func(s *serveSysInfo) { s.RAMMiB = 0 }},
|
||||
{name: "RAM libre trop juste pour 32 points", notes: 1,
|
||||
si: func(s *serveSysInfo) { s.RAMAvailMiB = 8000 }},
|
||||
// Une liste par slot : 2 × 32 × ~150 Mio tient dans le quart de 50 Go, 4 × non.
|
||||
{name: "PARALLEL=2 : deux listes, encore dans le quart de la RAM libre", cfg: map[string]string{"PARALLEL": "2"},
|
||||
want: cms, notes: 1},
|
||||
{name: "PARALLEL=4 : quatre listes, trop pour la RAM libre", cfg: map[string]string{"PARALLEL": "4"}, notes: 1},
|
||||
{name: "--parallel 4 d'EXTRA_ARGS l'emporte sur PARALLEL", cfg: map[string]string{"PARALLEL": "1"},
|
||||
extra: []string{"--parallel", "4"}, notes: 1},
|
||||
{name: "CTX_CHECKPOINTS=16 passé", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
|
||||
want: []string{"--ctx-checkpoints", "16", "--checkpoint-min-step", "2048"}, notes: 1},
|
||||
{name: "CTX_CHECKPOINTS illisible", cfg: map[string]string{"CTX_CHECKPOINTS": "beaucoup"}, want: cms, notes: 2},
|
||||
{name: "CTX_CHECKPOINTS et LLAMA_ARG_CTX_CHECKPOINTS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
|
||||
want: cms, notes: 2,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] = "8" }},
|
||||
{name: "CTX_CHECKPOINTS et -ctxcp d'EXTRA_ARGS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
|
||||
extra: []string{"-ctxcp", "8"}, want: cms, notes: 1},
|
||||
{name: "moteur sans --ctx-checkpoints : CTX_CHECKPOINTS ignoré, dit", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
|
||||
notes: 2, si: func(s *serveSysInfo) { s.Help = helpRecent }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := hybridOld()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes := ckptArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("ckptArgs = %v, attendu %v", got, c.want)
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Place dans la ligne complète : avant EXTRA_ARGS, qui garde le dernier mot.
|
||||
func TestBuildServeArgsCkpt(t *testing.T) {
|
||||
args, _, notes := buildServeArgs(map[string]string{"NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", hybridOld())
|
||||
if got := strings.Join(args, " "); !strings.HasSuffix(got, "--checkpoint-min-step 2048 --jinja") {
|
||||
t.Fatalf("ligne = %s", got)
|
||||
}
|
||||
found := false
|
||||
for _, n := range notes {
|
||||
found = found || strings.Contains(n, "b10678")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("l'avis du moteur ancien manque : %q", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineBuildTrust(t *testing.T) {
|
||||
cases := []struct {
|
||||
src string
|
||||
build int
|
||||
want int
|
||||
}{
|
||||
{"downloaded", 10678, 10678},
|
||||
{"image", 11351, 11351},
|
||||
{"prebuilt", 10864, 10864},
|
||||
{"custom", 10678, 0}, // compilé ou fork : numérotation inconnue
|
||||
{"downloaded", 0, 0}, // --version illisible
|
||||
{"image", 1, 0}, // clone superficiel
|
||||
{"prebuilt", 4999, 0}, // sous le plancher
|
||||
{"downloaded", 5000, 5000},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := engineBuildTrust(c.src, c.build); got != c.want {
|
||||
t.Errorf("engineBuildTrust(%s, %d) = %d, attendu %d", c.src, c.build, got, c.want)
|
||||
}
|
||||
}
|
||||
if engineBuildNotice(10678) == "" || engineBuildNotice(engineMinRecommended) != "" || engineBuildNotice(0) != "" {
|
||||
t.Fatal("avis : seulement pour un build connu et antérieur au correctif")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningPreserveArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
notes int
|
||||
}{
|
||||
{name: "clé absente : rien, même sur un moteur qui connaît le drapeau"},
|
||||
{name: "on", cfg: map[string]string{"REASONING_PRESERVE": "on"}, want: []string{"--reasoning-preserve"}},
|
||||
{name: "OFF", cfg: map[string]string{"REASONING_PRESERVE": "OFF"}, want: []string{"--no-reasoning-preserve"}},
|
||||
{name: "illisible", cfg: map[string]string{"REASONING_PRESERVE": "peut-être"}, notes: 1},
|
||||
{name: "moteur sans le drapeau", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.Help = helpRecent }},
|
||||
{name: "EXTRA_ARGS tranche déjà", cfg: map[string]string{"REASONING_PRESERVE": "off"},
|
||||
extra: []string{"--reasoning-preserve"}},
|
||||
{name: "--chat-template-kwargs le fixe", cfg: map[string]string{"REASONING_PRESERVE": "off"},
|
||||
extra: []string{"--chat-template-kwargs", `{"preserve_reasoning":true}`}, notes: 1},
|
||||
{name: "LLAMA_ARG_REASONING_PRESERVE posée", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] = "1" }},
|
||||
{name: "LLAMA_ARG_CHAT_TEMPLATE_KWARGS le fixe", cfg: map[string]string{"REASONING_PRESERVE": "on"}, notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"] = `{"preserve_reasoning":false}`
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := serveSysInfo{Help: helpCkpt, ArgEnv: map[string]string{}}
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes := reasoningPreserveArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("reasoningPreserveArgs = %v, attendu %v", got, c.want)
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Après une mise à jour, la version téléchargée qui tournait reste installée :
|
||||
// c'est le retour arrière sans réseau. Le moteur de l'image, lui, n'a rien à
|
||||
// garder sous engine/.
|
||||
func TestEngineKeepAfterUpdate(t *testing.T) {
|
||||
testHome(t)
|
||||
for _, tag := range []string{"server-cuda-b10500", "server-cuda-b10678", "server-cuda-b10900"} {
|
||||
d := filepath.Join(engineDir(), tag)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
nouveau := filepath.Join(engineDir(), "server-cuda-b10900", "llama-server")
|
||||
ancien := filepath.Join(engineDir(), "server-cuda-b10678", "llama-server")
|
||||
keep := engineKeepAfterUpdate(nouveau, ancien, nil)
|
||||
if !reflect.DeepEqual(keep, map[string]bool{"server-cuda-b10900": true, "server-cuda-b10678": true}) {
|
||||
t.Fatalf("gardés = %v", keep)
|
||||
}
|
||||
enginePrune(keep, nil)
|
||||
if got := engineInstalled(); !reflect.DeepEqual(got, []string{"server-cuda-b10678", "server-cuda-b10900"}) {
|
||||
t.Fatalf("après ménage : %v", got)
|
||||
}
|
||||
if keep := engineKeepAfterUpdate(nouveau, "/app/llama-server", nil); len(keep) != 1 {
|
||||
t.Fatalf("moteur de l'image : %v", keep)
|
||||
}
|
||||
if keep := engineKeepAfterUpdate(nouveau, nouveau, nil); len(keep) != 1 {
|
||||
t.Fatalf("même version : %v", keep)
|
||||
}
|
||||
}
|
||||
|
||||
// Nombre de slots : chacun a sa liste de points de reprise.
|
||||
func TestCkptSlots(t *testing.T) {
|
||||
cases := []struct {
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
want int
|
||||
}{
|
||||
{want: 1},
|
||||
{cfg: map[string]string{"PARALLEL": "3"}, want: 3},
|
||||
{cfg: map[string]string{"PARALLEL": "3"}, extra: []string{"-np", "2"}, want: 2},
|
||||
{extra: []string{"--parallel=6"}, want: 6},
|
||||
{extra: []string{"--parallel", "-1"}, want: 4},
|
||||
{cfg: map[string]string{"PARALLEL": "auto"}, want: 4},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ckptSlots(c.cfg, c.extra); got != c.want {
|
||||
t.Errorf("ckptSlots(%v, %v) = %d, attendu %d", c.cfg, c.extra, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Réglages d'expert du moteur local, tous éteints tant que le preset ne les
|
||||
// demande pas. Aucun ne touche au modèle : ni poids, ni contexte, ni
|
||||
// échantillonnage — seulement OÙ et COMMENT le même calcul s'exécute.
|
||||
//
|
||||
// FIT_TARGET=1024,3072 → --fit-target : marge VRAM laissée libre par carte
|
||||
// OP_OFFLOAD_MIN_BATCH=N → GGML_OP_OFFLOAD_MIN_BATCH : seuil d'envoi au GPU
|
||||
// des poids restés en RAM
|
||||
// CUDA_GRAPH_OPT=on → GGML_CUDA_GRAPH_OPT=1 : branches Q/K/V en parallèle
|
||||
//
|
||||
// Une variable déjà présente dans l'environnement (gabarit Docker/Unraid) est
|
||||
// un choix de l'utilisateur : jamais touchée. Les variables passent par
|
||||
// l'environnement du seul « loki serve », qui devient llama-server : le reste
|
||||
// du conteneur ne les voit pas.
|
||||
|
||||
// expertEnvKeys : les variables du moteur que ces réglages posent.
|
||||
var expertEnvKeys = []string{"GGML_CUDA_GRAPH_OPT", "GGML_OP_OFFLOAD_MIN_BATCH"}
|
||||
|
||||
// fitArgEnv : les LLAMA_ARG_* qui, sans drapeau, décident de --fit et de sa
|
||||
// marge. llama.cpp les applique quand la ligne de commande se tait.
|
||||
var fitArgEnv = []string{"LLAMA_ARG_FIT", "LLAMA_ARG_FIT_TARGET", "LLAMA_ARG_TENSOR_SPLIT"}
|
||||
|
||||
// probeExpertEnv relève ce que l'environnement a déjà tranché. Appelée après
|
||||
// probeServeGPUs, dont elle complète ArgEnv.
|
||||
func probeExpertEnv(si *serveSysInfo) {
|
||||
si.UserEnv = map[string]string{}
|
||||
for _, k := range expertEnvKeys {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
si.UserEnv[k] = v
|
||||
}
|
||||
}
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range fitArgEnv {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fitTargetMin : la marge par défaut de llama.cpp (common.h), en Mio. En
|
||||
// dessous, fit range plus serré que ce qu'il sait estimer — le tampon du modèle
|
||||
// MTP, qu'il ne mesure pas toujours, suffit alors à ramener le « cudaMalloc
|
||||
// failed: out of memory » au premier long prompt. On remonte, en le disant.
|
||||
const fitTargetMin = 1024
|
||||
|
||||
// fitTargetMaxValues : llama.cpp refuse (et le serveur meurt en boucle) une
|
||||
// liste aussi longue que son nombre maximal de cartes ; 8 reste loin du bord.
|
||||
const fitTargetMaxValues = 8
|
||||
|
||||
// parseFitTarget valide FIT_TARGET : des entiers en Mio séparés par « , » ou
|
||||
// « / », une valeur par carte dans l'ordre que voit le moteur (une seule vaut
|
||||
// pour toutes). Jamais transmis brut : llama.cpp lit chaque valeur avec
|
||||
// std::stoull, qui lève sur le moindre caractère parasite — moteur mort au
|
||||
// démarrage, relancé en boucle par systemd ou Docker.
|
||||
func parseFitTarget(v string) (vals []int, raised bool, ok bool) {
|
||||
parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '/' })
|
||||
if len(parts) == 0 || len(parts) > fitTargetMaxValues ||
|
||||
strings.Count(v, ",")+strings.Count(v, "/") != len(parts)-1 {
|
||||
return nil, false, false
|
||||
}
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || strings.Trim(p, "0123456789") != "" || len(p) > 7 {
|
||||
return nil, false, false
|
||||
}
|
||||
n, _ := strconv.Atoi(p)
|
||||
if n < fitTargetMin {
|
||||
n, raised = fitTargetMin, true
|
||||
}
|
||||
vals = append(vals, n)
|
||||
}
|
||||
return vals, raised, true
|
||||
}
|
||||
|
||||
// fitTargetArgs traduit FIT_TARGET en --fit-target. Fonction pure.
|
||||
//
|
||||
// La marge règle combien de VRAM --fit laisse libre sur chaque carte ; sur deux
|
||||
// cartes inégales (5060 Ti + 3060), une marge plus large sur la lente pousse
|
||||
// plus de couches vers la rapide. Placement seulement : mêmes poids, mêmes
|
||||
// types de cache, même contexte. Rien n'est posé quand :
|
||||
//
|
||||
// - le moteur ne connaît pas --fit-target (un drapeau inconnu le tue) ;
|
||||
// - EXTRA_ARGS ou LLAMA_ARG_FIT_TARGET l'ont déjà fixée ;
|
||||
// - le contexte n'est pas un nombre positif : avec -c 0, fit a le droit de
|
||||
// RÉDUIRE le contexte pour tenir, et une marge plus large l'y pousserait
|
||||
// sans un mot — de l'information perdue pour le modèle ;
|
||||
// - --fit ne tournera pas (voir fitBlocker) : la clé ne ferait rien, et une
|
||||
// comparaison A/B mesurerait deux fois le même placement.
|
||||
func fitTargetArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
|
||||
raw := strings.TrimSpace(cfg["FIT_TARGET"])
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ignore := func(why string) (a, n []string) {
|
||||
return nil, []string{"FIT_TARGET=" + raw + " ignoré : " + why}
|
||||
}
|
||||
vals, raised, ok := parseFitTarget(raw)
|
||||
switch {
|
||||
case !ok:
|
||||
return ignore("des Mio par carte, ex. 1024,3072 (8 valeurs au plus)")
|
||||
case !strings.Contains(si.Help, "--fit-target"):
|
||||
return ignore("ce moteur ne connaît pas --fit-target")
|
||||
case hasAnyFlag(extra, "-fitt", "--fit-target"):
|
||||
return ignore("--fit-target déjà dans EXTRA_ARGS")
|
||||
case si.ArgEnv["LLAMA_ARG_FIT_TARGET"] != "":
|
||||
return ignore("LLAMA_ARG_FIT_TARGET déjà posé dans l'environnement")
|
||||
}
|
||||
if ctx, ok := ctxFixed(cfg, extra); !ok {
|
||||
return ignore("contexte « " + ctx + " » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré")
|
||||
}
|
||||
if why := fitBlocker(cfg, extra, si); why != "" {
|
||||
return ignore("--fit inactif (" + why + "), la marge ne servirait à rien")
|
||||
}
|
||||
s := make([]string, len(vals))
|
||||
for i, n := range vals {
|
||||
s[i] = strconv.Itoa(n)
|
||||
}
|
||||
v := strings.Join(s, ",")
|
||||
note := "--fit-target " + v + " (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — " +
|
||||
"vérifie « offloaded N/N layers » dans le journal"
|
||||
if raised {
|
||||
note += " ; marges sous 1024 Mio remontées à 1024"
|
||||
}
|
||||
return []string{"--fit-target", v}, []string{note}
|
||||
}
|
||||
|
||||
// fitBlocker dit pourquoi llama.cpp ne lancera PAS --fit (common/fit.cpp :
|
||||
// « already set by user, abort »), ou vide s'il le lancera. Les couches GPU
|
||||
// sont lues comme buildServeArgs les écrira : EXTRA_ARGS d'abord, sinon la
|
||||
// traduction de NGL par nglArgs (999 y devient auto), sinon — NGL=auto, aucun
|
||||
// drapeau — la variable du moteur.
|
||||
func fitBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
|
||||
fit := flagValue(extra, "-fit", "--fit")
|
||||
if fit == "" {
|
||||
fit = si.ArgEnv["LLAMA_ARG_FIT"]
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(fit)) {
|
||||
case "off", "disabled", "false", "0":
|
||||
return "--fit off"
|
||||
}
|
||||
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
|
||||
if ngl == "" {
|
||||
if a, _ := nglArgs(cfg["NGL"], helpFitsLayersItself(si.Help)); len(a) == 2 {
|
||||
ngl = a[1]
|
||||
} else {
|
||||
ngl = si.ArgEnv["LLAMA_ARG_N_GPU_LAYERS"]
|
||||
}
|
||||
}
|
||||
// -1 est la valeur par défaut du moteur, celle qu'« auto » écrit : fit ne
|
||||
// la prend pas pour un choix de l'utilisateur.
|
||||
if ngl = strings.TrimSpace(ngl); ngl != "" && ngl != "-1" && !strings.EqualFold(ngl, "auto") {
|
||||
return "couches GPU fixées à " + ngl
|
||||
}
|
||||
if hasAnyFlag(extra, "-ts", "--tensor-split") || si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] != "" {
|
||||
return "--tensor-split"
|
||||
}
|
||||
if r := tensorOverride(extra, si.ArgEnv); r != "" {
|
||||
return r
|
||||
}
|
||||
sm := flagValue(extra, "-sm", "--split-mode")
|
||||
if sm == "" {
|
||||
sm = si.ArgEnv["LLAMA_ARG_SPLIT_MODE"]
|
||||
}
|
||||
// row ET tensor : fit abandonne sur l'une comme sur l'autre (« not
|
||||
// implemented », common/fit.cpp).
|
||||
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
|
||||
return "-sm " + sm
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// opOffloadMinBatchEnv traduit OP_OFFLOAD_MIN_BATCH en GGML_OP_OFFLOAD_MIN_BATCH.
|
||||
// Fonction pure.
|
||||
//
|
||||
// Quand des poids restent en RAM (experts MoE sur CPU), llama.cpp recopie vers
|
||||
// le GPU ceux dont un lot a besoin dès qu'il compte au moins 32 jetons ; en
|
||||
// dessous, il calcule sur CPU. Monter le seuil garde sur CPU les lots plus
|
||||
// courts : utile seulement si une spéculation ngram propose des brouillons de
|
||||
// 32 jetons ou plus (les lots de vérification MTP, quelques jetons, restent
|
||||
// déjà sous 32). Le prix : un tour d'outil de 32 à N-1 jetons se calcule lui
|
||||
// aussi sur CPU. N se trouve à la mesure, pas d'office. Même distribution ;
|
||||
// les logits ne sont pas identiques au bit près, comme déjà entre décodage CPU
|
||||
// et prefill GPU.
|
||||
func opOffloadMinBatchEnv(cfg map[string]string, si serveSysInfo) (val string, notes []string) {
|
||||
raw := strings.TrimSpace(cfg["OP_OFFLOAD_MIN_BATCH"])
|
||||
if raw == "" || si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"] != "" {
|
||||
return "", nil
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 1<<20 {
|
||||
return "", []string{"OP_OFFLOAD_MIN_BATCH=" + raw + " ignoré : un nombre de jetons, ex. 128"}
|
||||
}
|
||||
v := strconv.Itoa(n)
|
||||
return v, []string{"GGML_OP_OFFLOAD_MIN_BATCH=" + v + " (OP_OFFLOAD_MIN_BATCH) : lots de moins de " + v +
|
||||
" jetons calculés sur CPU pour les poids restés en RAM"}
|
||||
}
|
||||
|
||||
// graphOptEnv traduit CUDA_GRAPH_OPT=on en GGML_CUDA_GRAPH_OPT=1. Fonction pure.
|
||||
//
|
||||
// Au décodage, llama.cpp lance alors les trois branches Q, K et V de chaque
|
||||
// couche sur des flux CUDA concurrents. Chaque opération garde son noyau et ses
|
||||
// entrées : sortie identique au jeton près en amont (PR #28198, perplexité
|
||||
// inchangée). Gain modeste — quelques % sur un MoE tout GPU, rien sur un dense,
|
||||
// dilué quand les experts tournent sur CPU ou que MTP vérifie par lots. Sans
|
||||
// effet avant #28198 en multi-GPU ou si les graphes CUDA sont coupés.
|
||||
//
|
||||
// Expérimental : sur une architecture qu'il n'a pas vue, le repérage des
|
||||
// branches peut s'arrêter sur un GGML_ASSERT, et le moteur redémarre en boucle.
|
||||
// Une variable ne se détecte pas dans l'aide du moteur : on le dit au
|
||||
// lancement, pour que « CUDA_GRAPH_OPT=off puis redémarrer » vienne à l'esprit.
|
||||
//
|
||||
// Refusé quand Q, K et V d'une même couche pourraient finir sur deux cartes
|
||||
// (-sm row/tensor, -ot visant l'attention) : #28198 en fait une condition
|
||||
// d'exactitude.
|
||||
func graphOptEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
|
||||
raw := strings.TrimSpace(cfg["CUDA_GRAPH_OPT"])
|
||||
switch strings.ToLower(raw) {
|
||||
case "", "off":
|
||||
return "", nil
|
||||
case "on":
|
||||
default:
|
||||
return "", []string{"CUDA_GRAPH_OPT=" + raw + " ignoré (on ou off)"}
|
||||
}
|
||||
if si.UserEnv["GGML_CUDA_GRAPH_OPT"] != "" {
|
||||
return "", nil
|
||||
}
|
||||
if why := qkvSplitRisk(extra, si.ArgEnv); why != "" {
|
||||
return "", []string{"CUDA_GRAPH_OPT ignoré : " + why + " peut séparer Q, K et V d'une couche entre cartes"}
|
||||
}
|
||||
return "1", []string{"GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un " +
|
||||
"GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre"}
|
||||
}
|
||||
|
||||
// qkvSplitRisk : un placement qui pourrait répartir les tenseurs d'attention
|
||||
// d'une même couche sur plusieurs appareils. La découpe par couches (défaut,
|
||||
// --tensor-split compris) garde chaque couche entière : rien à craindre.
|
||||
func qkvSplitRisk(extra []string, argEnv map[string]string) string {
|
||||
sm := flagValue(extra, "-sm", "--split-mode")
|
||||
if sm == "" {
|
||||
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
|
||||
}
|
||||
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
|
||||
return "-sm " + sm
|
||||
}
|
||||
for _, v := range append(flagValues(extra, "-ot", "--override-tensor"), argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]) {
|
||||
if strings.Contains(strings.ToLower(v), "attn") {
|
||||
return "-ot sur l'attention"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helpFit : un moteur récent qui connaît --fit et sa marge.
|
||||
const helpFit = helpRecent + `-fit, --fit [on|off] whether to adjust unset arguments to fit in device memory
|
||||
-fitt, --fit-target MiB0,MiB1,... target margin per device for --fit (default: 1024)
|
||||
`
|
||||
|
||||
// TestFitTargetArgs : --fit-target n'est passé que validé, sur un moteur qui le
|
||||
// connaît, avec un contexte fixé, et quand --fit tournera vraiment.
|
||||
func TestFitTargetArgs(t *testing.T) {
|
||||
fit := serveSysInfo{Help: helpFit}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want []string
|
||||
wantNotes int
|
||||
}{
|
||||
{name: "clé absente : rien, sans un mot",
|
||||
cfg: map[string]string{}, si: fit},
|
||||
{name: "deux marges : passées, et on le dit",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: fit,
|
||||
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
|
||||
{name: "séparateur / : normalisé en virgules",
|
||||
cfg: map[string]string{"FIT_TARGET": "2048/1536"}, si: fit,
|
||||
want: []string{"--fit-target", "2048,1536"}, wantNotes: 1},
|
||||
{name: "une seule valeur : vaut pour toutes les cartes",
|
||||
cfg: map[string]string{"FIT_TARGET": " 2048 "}, si: fit,
|
||||
want: []string{"--fit-target", "2048"}, wantNotes: 1},
|
||||
{name: "marge sous le défaut : remontée à 1024",
|
||||
cfg: map[string]string{"FIT_TARGET": "256,3072"}, si: fit,
|
||||
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
|
||||
{name: "valeur non numérique : jamais transmise (stoull ferait mourir le moteur)",
|
||||
cfg: map[string]string{"FIT_TARGET": "1G,2G"}, si: fit, wantNotes: 1},
|
||||
{name: "valeur négative : refusée",
|
||||
cfg: map[string]string{"FIT_TARGET": "-1024"}, si: fit, wantNotes: 1},
|
||||
{name: "séparateur doublé : refusé",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,,2048"}, si: fit, wantNotes: 1},
|
||||
{name: "trop de valeurs : refusé",
|
||||
cfg: map[string]string{"FIT_TARGET": "1,2,3,4,5,6,7,8,9"}, si: fit, wantNotes: 1},
|
||||
{name: "moteur sans --fit-target : rien, le drapeau le tuerait",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: serveSysInfo{Help: helpRecent}, wantNotes: 1},
|
||||
{name: "-fitt déjà dans EXTRA_ARGS : l'utilisateur a tranché",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-fitt 2048"}, si: fit, wantNotes: 1},
|
||||
{name: "LLAMA_ARG_FIT_TARGET dans l'environnement : intouché",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024"},
|
||||
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT_TARGET": "4096"}}, wantNotes: 1},
|
||||
{name: "CTX=0 : fit pourrait réduire le contexte, refusé",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,3072", "CTX": "0"}, si: fit, wantNotes: 1},
|
||||
{name: "-c 0 dans EXTRA_ARGS l'emporte sur CTX : refusé",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "CTX": "65536", "EXTRA_ARGS": "-c 0"}, si: fit, wantNotes: 1},
|
||||
{name: "CTX absent : Loki passe -c 32768, accepté",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "NGL=999 : devient -ngl auto, fit tourne",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "999"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "NGL chiffré : fit abandonne, rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40"}, si: fit, wantNotes: 1},
|
||||
{name: "NGL=all : fit abandonne, rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "all"}, si: fit, wantNotes: 1},
|
||||
{name: "-ngl auto dans EXTRA_ARGS : fit tourne",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40", "EXTRA_ARGS": "-ngl auto"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=30 : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "auto"},
|
||||
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "30"}}, wantNotes: 1},
|
||||
{name: "--tensor-split : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--tensor-split 0.965,0.035"}, si: fit, wantNotes: 1},
|
||||
{name: "preset MoE (-ot, --n-cpu-moe) : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`},
|
||||
si: fit, wantNotes: 1},
|
||||
{name: "--cpu-moe : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-cmoe"}, si: fit, wantNotes: 1},
|
||||
{name: "--fit off : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--fit off"}, si: fit, wantNotes: 1},
|
||||
{name: "LLAMA_ARG_FIT=off : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024"},
|
||||
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT": "off"}}, wantNotes: 1},
|
||||
{name: "-sm row : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm row"}, si: fit, wantNotes: 1},
|
||||
{name: "-sm tensor : fit abandonne aussi, rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--split-mode tensor"}, si: fit, wantNotes: 1},
|
||||
{name: "LLAMA_ARG_SPLIT_MODE=tensor : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024"},
|
||||
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
|
||||
{name: "-sm layer : fit tourne",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm layer"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "-ngl -1 (le défaut du moteur) : fit tourne",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-ngl -1"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "--device choisi : le placement reste à fit, accepté",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,3072", "EXTRA_ARGS": "--device CUDA1,CUDA0"}, si: fit,
|
||||
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, notes := fitTargetArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("args = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphOptEnv : GGML_CUDA_GRAPH_OPT=1 seulement sur demande, jamais
|
||||
// par-dessus l'environnement, jamais quand Q/K/V pourraient changer de carte.
|
||||
func TestGraphOptEnv(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want string
|
||||
wantNotes int
|
||||
}{
|
||||
{name: "clé absente : rien", cfg: map[string]string{}},
|
||||
{name: "off : rien", cfg: map[string]string{"CUDA_GRAPH_OPT": "off"}},
|
||||
{name: "on : posé, et on le dit", cfg: map[string]string{"CUDA_GRAPH_OPT": "on"}, want: "1", wantNotes: 1},
|
||||
{name: "ON (casse libre) : posé", cfg: map[string]string{"CUDA_GRAPH_OPT": "ON"}, want: "1", wantNotes: 1},
|
||||
{name: "valeur inconnue : ignorée, dite", cfg: map[string]string{"CUDA_GRAPH_OPT": "2"}, wantNotes: 1},
|
||||
{name: "variable déjà posée (même à 0) : intouchée",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
|
||||
si: serveSysInfo{UserEnv: map[string]string{"GGML_CUDA_GRAPH_OPT": "0"}}},
|
||||
{name: "découpe par couches + --tensor-split : couches entières, posé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "--device CUDA1,CUDA0 -ts 0.6,0.4"},
|
||||
want: "1", wantNotes: 1},
|
||||
{name: "-sm row : refusé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "-sm row"}, wantNotes: 1},
|
||||
{name: "LLAMA_ARG_SPLIT_MODE=tensor : refusé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
|
||||
si: serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
|
||||
{name: "-ot sur l'attention : refusé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "blk\.1\.attn_k.*=CUDA1"`}, wantNotes: 1},
|
||||
{name: "-ot des experts seulement : posé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "ffn_.*_exps=CPU" --n-cpu-moe 40`},
|
||||
want: "1", wantNotes: 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, notes := graphOptEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
|
||||
if got != c.want {
|
||||
t.Fatalf("valeur = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpOffloadMinBatchEnv : la variable n'existe que si la clé est posée et
|
||||
// valide, et ne remplace jamais celle de l'environnement.
|
||||
func TestOpOffloadMinBatchEnv(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want string
|
||||
wantNotes int
|
||||
}{
|
||||
{name: "clé absente : rien", cfg: map[string]string{}},
|
||||
{name: "128 : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"}, want: "128", wantNotes: 1},
|
||||
{name: "espaces autour : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": " 64 "}, want: "64", wantNotes: 1},
|
||||
{name: "0 : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "0"}, wantNotes: 1},
|
||||
{name: "texte : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "beaucoup"}, wantNotes: 1},
|
||||
{name: "variable déjà posée : intouchée",
|
||||
cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"},
|
||||
si: serveSysInfo{UserEnv: map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "32"}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, notes := opOffloadMinBatchEnv(c.cfg, c.si)
|
||||
if got != c.want {
|
||||
t.Fatalf("valeur = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Les trois clés absentes, la ligne de commande et l'environnement ne bougent
|
||||
// pas ; posées, --fit-target arrive avant EXTRA_ARGS et les variables vont
|
||||
// dans env, nulle part ailleurs.
|
||||
func TestBuildServeArgsExpertToggles(t *testing.T) {
|
||||
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
|
||||
si := serveSysInfo{Help: helpFit, Model: model}
|
||||
cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0"}
|
||||
base, baseEnv, baseNotes := buildServeArgs(cfg, splitArgs(cfg["EXTRA_ARGS"]), bin, si)
|
||||
if len(baseEnv) != 0 || len(baseNotes) != 0 {
|
||||
t.Fatalf("sans les clés : env = %v, notes = %q", baseEnv, baseNotes)
|
||||
}
|
||||
on := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0",
|
||||
"FIT_TARGET": "1024,3072", "OP_OFFLOAD_MIN_BATCH": "128", "CUDA_GRAPH_OPT": "on"}
|
||||
args, env, notes := buildServeArgs(on, splitArgs(on["EXTRA_ARGS"]), bin, si)
|
||||
n := len(base)
|
||||
want := append(append(append([]string{}, base[:n-2]...), "--fit-target", "1024,3072"), base[n-2:]...)
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("args\n got %q\nwant %q", args, want)
|
||||
}
|
||||
wantEnv := map[string]string{"GGML_CUDA_GRAPH_OPT": "1", "GGML_OP_OFFLOAD_MIN_BATCH": "128"}
|
||||
if !reflect.DeepEqual(env, wantEnv) {
|
||||
t.Fatalf("env = %v, want %v", env, wantEnv)
|
||||
}
|
||||
if len(notes) != 3 {
|
||||
t.Fatalf("notes = %q", notes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Garde-fous de fidélité. Loki ne choisit JAMAIS à la place de l'utilisateur un
|
||||
// réglage qui change ce que le modèle calcule : pas de cache KV quantifié posé
|
||||
// d'office, pas de réutilisation approximative du cache, pas de contexte qui
|
||||
// glisse en jetant des jetons. Ces réglages restent possibles — ce sont des
|
||||
// compromis légitimes quand la VRAM manque — mais ils doivent se VOIR : un
|
||||
// preset qui tourne en q8_0 depuis EXTRA_ARGS pendant que l'interface affiche
|
||||
// « f16 » est exactement l'altération silencieuse qu'on refuse.
|
||||
//
|
||||
// Tout ici est pur : buildServeArgs en tire des notes, cmdServe les écrit.
|
||||
|
||||
// fidelityArgEnv : les LLAMA_ARG_* qui, sans drapeau, posent un cache
|
||||
// quantifié, un cache approché ou la vision. llama.cpp les applique AVANT la
|
||||
// ligne de commande : un -ctk de KV_TYPE ou d'EXTRA_ARGS les écrase, mais seuls
|
||||
// ils décident. LLAMA_ARG_NO_CONTEXT_SHIFT est celle des moteurs anciens.
|
||||
var fidelityArgEnv = []string{"LLAMA_ARG_CACHE_TYPE_K", "LLAMA_ARG_CACHE_TYPE_V",
|
||||
"LLAMA_ARG_CONTEXT_SHIFT", "LLAMA_ARG_NO_CONTEXT_SHIFT", "LLAMA_ARG_CACHE_REUSE",
|
||||
"LLAMA_ARG_MMPROJ", "LLAMA_ARG_MMPROJ_URL"}
|
||||
|
||||
// probeFidelityEnv complète ArgEnv avec fidelityArgEnv. Appelée après
|
||||
// probeServeGPUs, qui crée ArgEnv.
|
||||
func probeFidelityEnv(si *serveSysInfo) {
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range fidelityArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveKVTypes donne les types de cache K et V que le moteur utilisera
|
||||
// VRAIMENT. Trois sources, de la plus faible à la plus forte : les variables
|
||||
// LLAMA_ARG_CACHE_TYPE_K/V (lues avant la ligne de commande), le preset
|
||||
// (serveKVTypes, traduit en -ctk/-ctv), puis EXTRA_ARGS, qui ferme la ligne :
|
||||
// llama-server retient la dernière occurrence d'un drapeau, un -ctk q8_0 écrit
|
||||
// à la main l'emporte donc sur KV_TYPE. src nomme la source la plus forte qui a
|
||||
// tranché l'un des deux (vide : aucune). Vide = défaut du moteur (f16).
|
||||
func effectiveKVTypes(cfg map[string]string, extra []string, argEnv map[string]string) (k, v, src string) {
|
||||
k, v = argEnv["LLAMA_ARG_CACHE_TYPE_K"], argEnv["LLAMA_ARG_CACHE_TYPE_V"]
|
||||
if k != "" || v != "" {
|
||||
src = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
|
||||
}
|
||||
if ck, cv := serveKVTypes(cfg); ck != "" || cv != "" {
|
||||
src = "KV_TYPE"
|
||||
if ck != "" {
|
||||
k = ck
|
||||
}
|
||||
if cv != "" {
|
||||
v = cv
|
||||
}
|
||||
}
|
||||
if s := flagValue(extra, "-ctk", "--cache-type-k"); s != "" {
|
||||
k, src = s, "défini par EXTRA_ARGS"
|
||||
}
|
||||
if s := flagValue(extra, "-ctv", "--cache-type-v"); s != "" {
|
||||
v, src = s, "défini par EXTRA_ARGS"
|
||||
}
|
||||
return k, v, src
|
||||
}
|
||||
|
||||
// kvFidelity classe un type de cache par rapport au f16 de référence. Rang 0 =
|
||||
// mêmes calculs (f16, ou f32 qui ne perd rien) ; plus le rang monte, plus les
|
||||
// sorties s'en écartent. Un type inconnu (sorti après cette version) est
|
||||
// supposé altérer : on préfère un avertissement de trop à un silence.
|
||||
func kvFidelity(t string) (rank int, label string) {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "", "f16", "f32":
|
||||
return 0, ""
|
||||
case "bf16":
|
||||
return 1, "numérique différente de f16 (mantisse plus courte)"
|
||||
case "q8_0", "q5_0", "q5_1":
|
||||
return 2, "modifie légèrement les sorties par rapport à f16"
|
||||
case "q4_0", "q4_1", "iq4_nl":
|
||||
return 3, "perte mesurable par rapport à f16"
|
||||
default:
|
||||
return 2, "modifie les sorties par rapport à f16"
|
||||
}
|
||||
}
|
||||
|
||||
// kvFidelityNote dit, quand le cache n'est pas en f16, ce que ce choix coûte en
|
||||
// fidélité et d'où il vient. Le choix reste celui du preset : Loki ne le change
|
||||
// jamais, ni dans un sens ni dans l'autre. Pas d'estimation de la VRAM qu'un
|
||||
// retour en f16 demanderait : sans les métadonnées du GGUF, ce serait un chiffre
|
||||
// inventé. En revanche, si le placement est figé à la main (-ot, --n-cpu-moe N>0,
|
||||
// en drapeau ou en variable : tensorOverride), --fit ne tourne pas et ne
|
||||
// rattrapera pas ce surplus — mieux vaut le savoir avant de basculer et de
|
||||
// finir en « out of memory ».
|
||||
func kvFidelityNote(k, v, src string, extra []string, argEnv map[string]string) string {
|
||||
rk, lk := kvFidelity(k)
|
||||
rv, lv := kvFidelity(v)
|
||||
if rk == 0 && rv == 0 {
|
||||
return ""
|
||||
}
|
||||
label := lk
|
||||
if rv > rk {
|
||||
label = lv
|
||||
}
|
||||
note := fmt.Sprintf("cache KV %s/%s (%s) : %s — choix de l'utilisateur, laissé tel quel.",
|
||||
orF16(k), orF16(v), src, label)
|
||||
if tensorOverride(extra, argEnv) != "" {
|
||||
note += " Repasser en f16 demande plus de VRAM, et le placement fixé par -ot/--n-cpu-moe " +
|
||||
"empêche --fit de compenser : il faudrait sans doute relever --n-cpu-moe."
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
func orF16(t string) string {
|
||||
if t == "" {
|
||||
return "f16"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// lossyCacheNotes avertit des réglages de cache qui changent ce que voit le
|
||||
// modèle. Seuls deux le font :
|
||||
//
|
||||
// - le glissement de contexte : contexte plein → le moteur JETTE des jetons
|
||||
// anciens et continue, le modèle perd une partie de la conversation sans le
|
||||
// savoir ;
|
||||
// - --cache-reuse N (N > 0) : recolle des morceaux de cache calculés sous un
|
||||
// AUTRE préfixe, simplement décalés — le résultat n'est plus celui d'un
|
||||
// calcul complet. --cache-reuse 0 le désactive : rien à dire.
|
||||
//
|
||||
// --swa-full n'en fait PAS partie : il garde le cache complet des couches à
|
||||
// fenêtre glissante, ce qui rend la réutilisation du préfixe exacte (au prix de
|
||||
// VRAM), et ne fait rien sur un modèle sans SWA. Correspondance exacte des noms
|
||||
// (flagValue) : --no-context-shift ne déclenche rien, et la dernière occurrence
|
||||
// gagne comme dans llama-server. Les variables LLAMA_ARG_* comptent quand la
|
||||
// ligne de commande se tait.
|
||||
//
|
||||
// Avec la vision chargée (MMPROJ, --mmproj d'EXTRA_ARGS ou LLAMA_ARG_MMPROJ),
|
||||
// llama.cpp désactive lui-même les deux (non pris en charge en multimodal) : on
|
||||
// le dit plutôt que de crier au loup. Les modèles hybrides ou récurrents les
|
||||
// ignorent aussi, mais sans métadonnées du GGUF on ne sait pas les reconnaître
|
||||
// ici : on se tait sur ce point plutôt que deviner.
|
||||
func lossyCacheNotes(extra []string, si serveSysInfo) []string {
|
||||
var notes []string
|
||||
ignored := ""
|
||||
if si.MMProj != "" || hasAnyFlag(extra, "-mm", "--mmproj", "-mmu", "--mmproj-url") ||
|
||||
si.ArgEnv["LLAMA_ARG_MMPROJ"] != "" || si.ArgEnv["LLAMA_ARG_MMPROJ_URL"] != "" {
|
||||
ignored = " (vision chargée : llama.cpp l'ignore de toute façon)"
|
||||
}
|
||||
switch on, src := contextShift(extra, si.ArgEnv, si.Help); {
|
||||
case on && src == "":
|
||||
notes = append(notes, "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte "+
|
||||
"plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+
|
||||
ignored+". --no-context-shift dans EXTRA_ARGS l'en empêche.")
|
||||
case on:
|
||||
notes = append(notes, "avertissement : --context-shift ("+src+") — contexte plein, le moteur jette "+
|
||||
"des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+ignored+".")
|
||||
}
|
||||
s, src := flagValue(extra, "--cache-reuse"), "EXTRA_ARGS"
|
||||
if s == "" {
|
||||
s, src = si.ArgEnv["LLAMA_ARG_CACHE_REUSE"], "LLAMA_ARG_CACHE_REUSE"
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 {
|
||||
notes = append(notes, fmt.Sprintf("avertissement : --cache-reuse %d (%s) — réutilise des morceaux "+
|
||||
"de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul "+
|
||||
"complet%s.", n, src, ignored))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// contextShift dit si le moteur glissera le contexte, et qui l'a décidé (vide :
|
||||
// son propre défaut). --context-shift et --no-context-shift peuvent cohabiter
|
||||
// dans EXTRA_ARGS (preset copié, puis corrigé) ; comme llama-server, la
|
||||
// dernière occurrence décide. Sans drapeau, la variable du moteur, puis son
|
||||
// défaut — qui a changé : les llama-server d'avant --context-shift (mi-2025) ne
|
||||
// connaissent que --no-context-shift et GLISSENT par défaut. Aide vide (moteur
|
||||
// inconnu) : on ne suppose rien.
|
||||
func contextShift(extra []string, argEnv map[string]string, help string) (on bool, src string) {
|
||||
set := false
|
||||
for _, a := range extra {
|
||||
name, _, _ := strings.Cut(a, "=")
|
||||
switch name {
|
||||
case "--context-shift":
|
||||
on, set = true, true
|
||||
case "--no-context-shift":
|
||||
on, set = false, true
|
||||
}
|
||||
}
|
||||
if set {
|
||||
return on, "EXTRA_ARGS"
|
||||
}
|
||||
if strings.Contains(help, "--no-context-shift") && !strings.Contains(help, "--context-shift") {
|
||||
// Moteur ancien : seul LLAMA_ARG_NO_CONTEXT_SHIFT (lu comme un booléen
|
||||
// vrai) l'arrête ; LLAMA_ARG_CONTEXT_SHIFT lui est inconnue.
|
||||
if envTruthy(argEnv["LLAMA_ARG_NO_CONTEXT_SHIFT"]) {
|
||||
return false, "LLAMA_ARG_NO_CONTEXT_SHIFT"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
if envTruthy(argEnv["LLAMA_ARG_CONTEXT_SHIFT"]) {
|
||||
return true, "LLAMA_ARG_CONTEXT_SHIFT"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEffectiveKVTypes(t *testing.T) {
|
||||
const ea, kv = "défini par EXTRA_ARGS", "KV_TYPE"
|
||||
const env = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra string
|
||||
argEnv map[string]string
|
||||
k, v string
|
||||
src string
|
||||
}{
|
||||
{"rien : défaut moteur", map[string]string{}, "", nil, "", "", ""},
|
||||
{"KV_TYPE seul", map[string]string{"KV_TYPE": "q8_0"}, "", nil, "q8_0", "q8_0", kv},
|
||||
{"EXTRA_ARGS l'emporte sur KV_TYPE", map[string]string{"KV_TYPE": "f16"},
|
||||
"--cache-type-k q8_0 --cache-type-v q8_0", nil, "q8_0", "q8_0", ea},
|
||||
{"formes courtes, la dernière gagne", map[string]string{},
|
||||
"-ctk q4_0 -ctv q4_0 -ctk q8_0", nil, "q8_0", "q4_0", ea},
|
||||
{"forme --flag=valeur", map[string]string{}, "--cache-type-v=q8_0", nil, "", "q8_0", ea},
|
||||
{"K seul dans EXTRA_ARGS, V du preset", map[string]string{"KV_TYPE_V": "q4_0"},
|
||||
"-ctk q8_0", nil, "q8_0", "q4_0", ea},
|
||||
// Les variables passent AVANT la ligne de commande : seules, elles
|
||||
// décident ; un -ctk venu de KV_TYPE les écrase.
|
||||
{"variables seules", map[string]string{}, "",
|
||||
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q8_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "q8_0", "q8_0", env},
|
||||
{"KV_TYPE_K écrase la variable K, la variable V reste", map[string]string{"KV_TYPE_K": "f16"}, "",
|
||||
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q4_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "f16", "q8_0", kv},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
k, v, src := effectiveKVTypes(c.cfg, splitArgs(c.extra), c.argEnv)
|
||||
if k != c.k || v != c.v || src != c.src {
|
||||
t.Fatalf("got %q/%q src=%q, want %q/%q src=%q", k, v, src, c.k, c.v, c.src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVFidelityNote(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
k, v string
|
||||
src string
|
||||
extra string
|
||||
argEnv map[string]string
|
||||
want []string // fragments attendus ; nil = aucune note
|
||||
fit bool // la note doit parler de --fit (placement figé)
|
||||
}{
|
||||
{"défaut moteur : rien à dire", "", "", "", "", nil, nil, false},
|
||||
{"f16 explicite : rien à dire", "f16", "f16", "KV_TYPE", "", nil, nil, false},
|
||||
{"f32 ne perd rien", "f32", "f32", "KV_TYPE", "", nil, nil, false},
|
||||
{"q8_0 : léger écart", "q8_0", "q8_0", "KV_TYPE", "", nil,
|
||||
[]string{"q8_0/q8_0", "KV_TYPE", "modifie légèrement"}, false},
|
||||
{"q4_0 : perte mesurable", "q4_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
|
||||
{"mixte : le pire des deux", "q8_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
|
||||
{"bf16 : numérique différente", "bf16", "bf16", "KV_TYPE", "", nil, []string{"mantisse"}, false},
|
||||
{"V seul quantifié", "", "q8_0", "KV_TYPE", "", nil, []string{"f16/q8_0"}, false},
|
||||
{"type inconnu : supposé altérer", "q3_k", "q3_k", "KV_TYPE", "", nil, []string{"modifie les sorties"}, false},
|
||||
{"venu d'EXTRA_ARGS, placement figé : --fit ne compense pas", "q8_0", "q8_0", "défini par EXTRA_ARGS",
|
||||
"-ot per_layer_token_embd.weight=CPU --n-cpu-moe 40 --cache-type-k q8_0", nil,
|
||||
[]string{"défini par EXTRA_ARGS", "relever --n-cpu-moe"}, true},
|
||||
// --n-cpu-moe 0 ne place rien : --fit tourne, rien à en dire.
|
||||
{"--n-cpu-moe 0 : placement libre", "q8_0", "q8_0", "défini par EXTRA_ARGS",
|
||||
"--n-cpu-moe 0 -ctk q8_0 -ctv q8_0", nil, []string{"q8_0/q8_0"}, false},
|
||||
{"experts sur CPU par variable : placement figé aussi", "q8_0", "q8_0", "KV_TYPE", "",
|
||||
map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}, []string{"relever --n-cpu-moe"}, true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := kvFidelityNote(c.k, c.v, c.src, splitArgs(c.extra), c.argEnv)
|
||||
if c.want == nil {
|
||||
if got != "" {
|
||||
t.Fatalf("note inattendue : %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Fatalf("note %q sans %q", got, w)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "--fit") != c.fit {
|
||||
t.Fatalf("note %q : mention de --fit attendue = %v", got, c.fit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLossyCacheNotes(t *testing.T) {
|
||||
// Aides des deux générations de llama-server : l'ancienne ne connaît que
|
||||
// --no-context-shift et glisse par défaut, la récente ne glisse pas.
|
||||
const helpShiftOld = "--no-context-shift disables context shift on infinite text generation (default: disabled)"
|
||||
const helpShiftNew = "--context-shift, --no-context-shift whether to use context shift on infinite text generation (default: disabled)"
|
||||
envOf := func(kv ...string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i]] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
extra string
|
||||
si serveSysInfo
|
||||
want []string // un fragment par note attendue, dans l'ordre
|
||||
}{
|
||||
{"rien", "", serveSysInfo{}, nil},
|
||||
{"--context-shift", "--context-shift", serveSysInfo{}, []string{"--context-shift (EXTRA_ARGS)"}},
|
||||
{"--no-context-shift ne déclenche rien", "--no-context-shift", serveSysInfo{}, nil},
|
||||
{"la dernière occurrence décide", "--context-shift --no-context-shift", serveSysInfo{}, nil},
|
||||
{"--cache-reuse 256", "--cache-reuse 256", serveSysInfo{}, []string{"--cache-reuse 256 (EXTRA_ARGS)"}},
|
||||
{"--cache-reuse=256", "--cache-reuse=256", serveSysInfo{}, []string{"--cache-reuse 256"}},
|
||||
{"--cache-reuse 0 : désactivé", "--cache-reuse 0", serveSysInfo{}, nil},
|
||||
{"--swa-full est sans perte", "--swa-full", serveSysInfo{}, nil},
|
||||
{"les deux", "--context-shift --cache-reuse 64", serveSysInfo{}, []string{"jette", "--cache-reuse 64"}},
|
||||
{"vision (MMPROJ) : llama.cpp les ignore, on le dit", "--context-shift --cache-reuse 64",
|
||||
serveSysInfo{MMProj: "/m/mmproj.gguf"}, []string{"l'ignore", "l'ignore"}},
|
||||
{"vision par --mmproj d'EXTRA_ARGS : pareil", "--mmproj mmproj-F16.gguf --cache-reuse 64",
|
||||
serveSysInfo{}, []string{"l'ignore"}},
|
||||
// Les variables du moteur comptent quand la ligne de commande se tait.
|
||||
{"LLAMA_ARG_CACHE_REUSE", "", serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")},
|
||||
[]string{"--cache-reuse 128 (LLAMA_ARG_CACHE_REUSE)"}},
|
||||
{"--cache-reuse 0 d'EXTRA_ARGS écrase la variable", "--cache-reuse 0",
|
||||
serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")}, nil},
|
||||
{"LLAMA_ARG_CONTEXT_SHIFT=1", "", serveSysInfo{Help: helpShiftNew, ArgEnv: envOf("LLAMA_ARG_CONTEXT_SHIFT", "1")},
|
||||
[]string{"(LLAMA_ARG_CONTEXT_SHIFT)"}},
|
||||
{"moteur récent sans drapeau : rien", "", serveSysInfo{Help: helpShiftNew}, nil},
|
||||
// Défaut d'un moteur ancien : il jette des jetons sans qu'on ait rien écrit.
|
||||
{"moteur ancien : glisse par défaut, on le dit", "", serveSysInfo{Help: helpShiftOld},
|
||||
[]string{"glisse le contexte par défaut"}},
|
||||
{"moteur ancien, --no-context-shift : rien", "--no-context-shift", serveSysInfo{Help: helpShiftOld}, nil},
|
||||
{"moteur ancien, LLAMA_ARG_NO_CONTEXT_SHIFT=1 : rien", "",
|
||||
serveSysInfo{Help: helpShiftOld, ArgEnv: envOf("LLAMA_ARG_NO_CONTEXT_SHIFT", "1")}, nil},
|
||||
{"moteur inconnu (aide vide) : on ne suppose rien", "", serveSysInfo{}, nil},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := lossyCacheNotes(splitArgs(c.extra), c.si)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("notes = %q, en attendait %d", got, len(c.want))
|
||||
}
|
||||
for i, w := range c.want {
|
||||
if !strings.Contains(got[i], w) {
|
||||
t.Fatalf("note %q sans %q", got[i], w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ce que buildServeArgs fait des garde-fous : JAMAIS de drapeau ajouté ou
|
||||
// retiré, seulement des notes. Sans KV_TYPE, aucun -ctk/-ctv : le moteur garde
|
||||
// son f16.
|
||||
func TestBuildServeArgsFidelite(t *testing.T) {
|
||||
const bin = "/opt/llama/llama-server"
|
||||
si := serveSysInfo{Help: helpRecent, Model: "/models/m.gguf"}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
notes []string // fragments attendus parmi les notes ; nil = aucune note de fidélité
|
||||
}{
|
||||
{"KV_TYPE absent : ni -ctk ni -ctv, rien à dire", map[string]string{"NGL": "28"}, nil},
|
||||
{"--swa-full : sans perte, silence", map[string]string{"NGL": "28", "EXTRA_ARGS": "--swa-full"}, nil},
|
||||
{"cache quantifié par EXTRA_ARGS : vu", map[string]string{"NGL": "28",
|
||||
"EXTRA_ARGS": "--cache-type-k q8_0 --cache-type-v q8_0"}, []string{"défini par EXTRA_ARGS"}},
|
||||
{"--cache-reuse 256 : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--cache-reuse 256"},
|
||||
[]string{"--cache-reuse 256"}},
|
||||
{"--context-shift : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--context-shift"},
|
||||
[]string{"--context-shift"}},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
extra := splitArgs(c.cfg["EXTRA_ARGS"])
|
||||
args, _, notes := buildServeArgs(c.cfg, extra, bin, si)
|
||||
if c.cfg["KV_TYPE"] == "" && !hasAnyFlag(extra, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") &&
|
||||
hasAnyFlag(args, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") {
|
||||
t.Fatalf("Loki a posé un type de cache de lui-même : %q", args)
|
||||
}
|
||||
// La ligne de commande se termine exactement par EXTRA_ARGS, intouché.
|
||||
if tail := args[len(args)-len(extra):]; strings.Join(tail, " ") != strings.Join(extra, " ") {
|
||||
t.Fatalf("EXTRA_ARGS modifié : %q", tail)
|
||||
}
|
||||
all := strings.Join(notes, "\n")
|
||||
if c.notes == nil {
|
||||
if strings.Contains(all, "cache KV") || strings.Contains(all, "avertissement") {
|
||||
t.Fatalf("note de fidélité inattendue : %q", notes)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, w := range c.notes {
|
||||
if !strings.Contains(all, w) {
|
||||
t.Fatalf("notes %q sans %q", notes, w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Aucune requête de Loki vers le moteur ne coupe le cache de préfixe
|
||||
// (cache_prompt:false) ni n'active la réutilisation approximative
|
||||
// (n_cache_reuse). On vérifie les corps RÉELLEMENT envoyés par le chat et par
|
||||
// la compaction, contre un faux llama-server local.
|
||||
func TestRequetesSansCacheApproximatif(t *testing.T) {
|
||||
testHome(t)
|
||||
var mu sync.Mutex
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
bodies = append(bodies, body)
|
||||
mu.Unlock()
|
||||
if body["stream"] == true {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(sseChunk("bonjour")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{},
|
||||
func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatalf("runChat : %v", err)
|
||||
}
|
||||
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
|
||||
t.Fatalf("compaction : %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(bodies) < 2 {
|
||||
t.Fatalf("%d requête(s) reçue(s), attendu chat + compaction", len(bodies))
|
||||
}
|
||||
for _, b := range bodies {
|
||||
if v, ok := b["cache_prompt"]; ok && v != true {
|
||||
t.Errorf("cache_prompt=%v envoyé au moteur : le préfixe serait recalculé ou faussé", v)
|
||||
}
|
||||
if _, ok := b["n_cache_reuse"]; ok {
|
||||
t.Errorf("n_cache_reuse envoyé au moteur : réutilisation approximative du cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filet sur tout le paquet : les clés cache_prompt et n_cache_reuse n'existent
|
||||
// dans le code QUE pour le benchmark, qui mesure le prefill et doit donc couper
|
||||
// le cache. Lecture de l'arbre syntaxique (pas du texte) : un commentaire qui
|
||||
// les mentionne ne compte pas, un nouveau fichier est couvert d'office.
|
||||
func TestCleCacheReserveeAuBench(t *testing.T) {
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed := map[string]bool{"llm_bench.go": true}
|
||||
fset := token.NewFileSet()
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") || allowed[f] {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(fset, f, nil, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
banned := func(s string) bool { return s == "cache_prompt" || s == "n_cache_reuse" }
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
// Un champ de structure sérialisé (étiquette json:"cache_prompt")
|
||||
// enverrait la clé aussi sûrement qu'une map : on lit les étiquettes.
|
||||
if f, ok := n.(*ast.Field); ok && f.Tag != nil {
|
||||
if tag, err := strconv.Unquote(f.Tag.Value); err == nil {
|
||||
if name, _, _ := strings.Cut(reflect.StructTag(tag).Get("json"), ","); banned(name) {
|
||||
t.Errorf("%s : champ %q hors du benchmark", fset.Position(f.Pos()), name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
lit, ok := n.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
if s, err := strconv.Unquote(lit.Value); err == nil && banned(s) {
|
||||
t.Errorf("%s : clé %q hors du benchmark", fset.Position(lit.Pos()), s)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHasAnyFlag(t *testing.T) {
|
||||
@@ -31,16 +33,25 @@ func TestNormalizeLoadFlags(t *testing.T) {
|
||||
supports bool
|
||||
want []string
|
||||
}{
|
||||
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
|
||||
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
|
||||
[]string{"--mlock", "--no-mmap"}},
|
||||
{"mlock traduit", []string{"--mlock", "-fa"}, true,
|
||||
[]string{"-fa", "--load-mode", "mlock"}},
|
||||
// L'ancien --mlock gardait le mmap : « mlock » de llama.cpp le coupe.
|
||||
{"mlock seul → mmap+mlock", []string{"--mlock", "-fa"}, true,
|
||||
[]string{"-fa", "--load-mode", "mmap+mlock"}},
|
||||
{"no-mmap traduit", []string{"--no-mmap"}, true,
|
||||
[]string{"--load-mode", "none"}},
|
||||
{"mmap traduit", []string{"--mmap"}, true,
|
||||
[]string{"--load-mode", "mmap"}},
|
||||
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
|
||||
{"mlock + no-mmap → mlock", []string{"--no-mmap", "--mlock"}, true,
|
||||
[]string{"--load-mode", "mlock"}},
|
||||
{"moteur ancien : none retraduit", []string{"-fa", "--load-mode", "none"}, false,
|
||||
[]string{"-fa", "--no-mmap"}},
|
||||
{"moteur ancien : mlock retraduit", []string{"--load-mode", "mlock"}, false,
|
||||
[]string{"--mlock", "--no-mmap"}},
|
||||
{"moteur ancien : mmap+mlock retraduit", []string{"-lm", "mmap+mlock"}, false,
|
||||
[]string{"--mlock"}},
|
||||
{"moteur ancien : dio abandonné", []string{"--load-mode=dio", "-fa"}, false,
|
||||
[]string{"-fa"}},
|
||||
{"--load-mode explicite gagne, vieux drapeaux retirés",
|
||||
[]string{"--mlock", "--load-mode", "dio"}, true,
|
||||
[]string{"--load-mode", "dio"}},
|
||||
@@ -49,10 +60,15 @@ func TestNormalizeLoadFlags(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := normalizeLoadFlags(c.in, c.supports)
|
||||
got, note := normalizeLoadFlags(c.in, c.supports)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("got %q, want %q", got, c.want)
|
||||
}
|
||||
// Seul « dio » abandonné sur un moteur ancien se signale : les autres
|
||||
// traductions gardent le sens demandé.
|
||||
if wantNote := c.name == "moteur ancien : dio abandonné"; (note != "") != wantNote {
|
||||
t.Fatalf("note = %q, attendue : %v", note, wantNote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -91,3 +107,32 @@ func TestNGLArgs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgValueEtPortLibre(t *testing.T) {
|
||||
if got := argValue([]string{"--port", "8080", "-c", "1", "--port", "9000"}, "--port"); got != "9000" {
|
||||
t.Fatalf("la dernière occurrence doit gagner, got %q", got)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
if waitPortFree("0.0.0.0", port, 600*time.Millisecond) == nil {
|
||||
t.Fatal("un port occupé doit être refusé")
|
||||
}
|
||||
ln.Close()
|
||||
if err := waitPortFree("0.0.0.0", port, time.Second); err != nil {
|
||||
t.Fatalf("un port libre doit passer : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowKV(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
k, v string
|
||||
lent bool
|
||||
}{{"", "", false}, {"q8_0", "q8_0", false}, {"q4_0", "q4_0", false}, {"q8_0", "q4_0", true}, {"q5_1", "q5_1", true}} {
|
||||
if slowKV(c.k, c.v) != c.lent {
|
||||
t.Errorf("%s/%s : lent attendu %v", c.k, c.v, c.lent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Files de lancement CUDA (CUDA_SCALE_LAUNCH_QUEUES) sur deux GPU ou plus.
|
||||
//
|
||||
// Quand llama.cpp répartit les couches entre plusieurs cartes et que le modèle
|
||||
// y tient en entier, il fait travailler les cartes en pipeline : pendant que la
|
||||
// seconde calcule un micro-lot, la première attaque le suivant. Encore faut-il
|
||||
// que le CPU puisse empiler assez de lancements CUDA d'avance ; avec la file par
|
||||
// défaut du pilote, il bloque et le pipeline se vide. « 4x » quadruple cette
|
||||
// file (PR llama.cpp #19042) : c'est une affaire de pilote, pas de calcul — mêmes
|
||||
// noyaux, mêmes entrées, même ordre, donc une sortie identique au jeton près. Le
|
||||
// prix : ~120 Mo de RAM système par GPU.
|
||||
//
|
||||
// llama.cpp l'a posé par défaut puis retiré (#19227) après des blocages sur
|
||||
// Jetson (mémoire unifiée, MoE décodé en partie sur CPU) ; ses docs le
|
||||
// recommandent désormais à la main en multi-GPU. Loki le pose donc lui-même,
|
||||
// mais seulement là où le pipeline peut réellement exister (voir
|
||||
// pipelineBlocker) : ailleurs le gain est nul et l'on ne s'exposerait qu'au
|
||||
// seul cas de panne jamais rapporté.
|
||||
//
|
||||
// Gain non mesuré sur la machine de référence (5060 Ti + 3060, 27B) : en amont,
|
||||
// +10 à 25 % de prompt seulement sur un 70B réparti sur deux grosses cartes,
|
||||
// rien de mesurable sur un 8B. Le décodage ne bouge pas. Pour vérifier qu'il
|
||||
// s'applique, le journal du moteur doit dire « pipeline parallelism enabled ».
|
||||
//
|
||||
// Choix de l'utilisateur, par ordre de priorité :
|
||||
//
|
||||
// CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement → intouché (conteneur Unraid…)
|
||||
// CUDA_LAUNCH_QUEUES=off → rien
|
||||
// CUDA_LAUNCH_QUEUES=0.25x|0.5x|2x|4x → cette valeur, sans condition
|
||||
// vide ou auto → 4x si ≥ 2 GPU et pipeline possible
|
||||
//
|
||||
// Une autre valeur serait lue comme 1x par le pilote, sans un mot : on l'ignore
|
||||
// en le disant.
|
||||
|
||||
const launchQueuesAuto = "4x"
|
||||
|
||||
// launchQueueValues : les seules valeurs que le pilote CUDA comprend.
|
||||
var launchQueueValues = map[string]bool{"0.25x": true, "0.5x": true, "2x": true, "4x": true}
|
||||
|
||||
// serveArgEnv : les variables LLAMA_ARG_* qui font, sans drapeau, ce que lit
|
||||
// pipelineBlocker. llama.cpp les applique quand le drapeau manque — les ignorer
|
||||
// ferait conclure à un pipeline que le moteur n'ouvrira pas.
|
||||
var serveArgEnv = []string{"LLAMA_ARG_DEVICE", "LLAMA_ARG_SPLIT_MODE", "LLAMA_ARG_N_GPU_LAYERS",
|
||||
"LLAMA_ARG_OVERRIDE_TENSOR", "LLAMA_ARG_CPU_MOE", "LLAMA_ARG_N_CPU_MOE", "LLAMA_ARG_N_CPU_FFN",
|
||||
"LLAMA_ARG_KV_OFFLOAD", "LLAMA_ARG_NO_KV_OFFLOAD"}
|
||||
|
||||
// launchQueuesEnv décide de CUDA_SCALE_LAUNCH_QUEUES. Fonction pure : le nombre
|
||||
// de GPU et l'environnement arrivent déjà sondés dans si. Valeur vide = ne rien
|
||||
// poser.
|
||||
func launchQueuesEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
|
||||
if si.LaunchQueues != "" {
|
||||
return "", nil
|
||||
}
|
||||
want := strings.ToLower(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]))
|
||||
switch {
|
||||
case want == "off":
|
||||
return "", nil
|
||||
case launchQueueValues[want]:
|
||||
return want, []string{"CUDA_SCALE_LAUNCH_QUEUES=" + want + " (CUDA_LAUNCH_QUEUES)"}
|
||||
case want != "" && want != "auto":
|
||||
notes = append(notes, "CUDA_LAUNCH_QUEUES="+cfg["CUDA_LAUNCH_QUEUES"]+
|
||||
" ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique")
|
||||
}
|
||||
n := servedGPUCount(extra, si)
|
||||
if n < 2 || pipelineBlocker(cfg, extra, si.ArgEnv) != "" {
|
||||
return "", notes
|
||||
}
|
||||
return launchQueuesAuto, append(notes, fmt.Sprintf("CUDA_SCALE_LAUNCH_QUEUES=%s (%d GPU, parallélisme pipeline "+
|
||||
"possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", launchQueuesAuto, n))
|
||||
}
|
||||
|
||||
// servedGPUCount : combien de GPU CUDA llama-server utilisera. Le --device du
|
||||
// preset d'abord (c'est là que l'éditeur range le choix des cartes), puis ce
|
||||
// que cmdServe a compté (CUDA_VISIBLE_DEVICES, sinon nvidia-smi). Un --device
|
||||
// qui ne nomme que des cartes Vulkan ou Metal compte zéro : la variable ne
|
||||
// concerne que CUDA.
|
||||
func servedGPUCount(extra []string, si serveSysInfo) int {
|
||||
dev := flagValue(extra, "-dev", "--device")
|
||||
if dev == "" {
|
||||
dev = si.ArgEnv["LLAMA_ARG_DEVICE"]
|
||||
}
|
||||
if dev == "" {
|
||||
return si.GPUs
|
||||
}
|
||||
n := 0
|
||||
for _, d := range strings.Split(dev, ",") {
|
||||
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(d)), "CUDA") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// pipelineBlocker dit pourquoi llama.cpp n'ouvrira PAS le pipeline entre
|
||||
// cartes (llama-context.cpp : toutes les couches sur GPU, découpe par couches,
|
||||
// cache KV sur GPU, aucune surcharge de tenseurs). Vide = il peut l'ouvrir.
|
||||
// --n-cpu-moe, --cpu-moe et --n-cpu-ffn sont des surcharges de tenseurs
|
||||
// déguisées : le preset MoE à experts sur CPU n'a donc jamais de pipeline.
|
||||
//
|
||||
// Attention à la façon dont llama.cpp lit ces réglages (common/arg.cpp) : les
|
||||
// variables LLAMA_ARG_* passent d'abord, la ligne de commande ensuite. Pour un
|
||||
// réglage à valeur unique (-sm, -ngl, -kvo/-nkvo), la ligne de commande
|
||||
// l'emporte ; mais les surcharges de tenseurs S'ACCUMULENT — variable et
|
||||
// drapeaux, chaque occurrence ajoute les siennes. Un --n-cpu-moe 0 n'annule
|
||||
// donc pas un LLAMA_ARG_N_CPU_MOE=30.
|
||||
func pipelineBlocker(cfg map[string]string, extra []string, argEnv map[string]string) string {
|
||||
if r := tensorOverride(extra, argEnv); r != "" {
|
||||
return r
|
||||
}
|
||||
if !kvOffloaded(extra, argEnv) {
|
||||
return "cache KV sur CPU"
|
||||
}
|
||||
sm := flagValue(extra, "-sm", "--split-mode")
|
||||
if sm == "" {
|
||||
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
|
||||
}
|
||||
if sm != "" && sm != "layer" {
|
||||
return "découpe " + sm
|
||||
}
|
||||
// Couches GPU : celles d'EXTRA_ARGS, sinon NGL (que buildServeArgs traduit
|
||||
// toujours en -ngl, sauf NGL=auto qui ne pose rien — la variable du moteur
|
||||
// décide alors). Un nombre choisi — hors la sentinelle 999 — laisse en
|
||||
// général des couches au CPU ; auto, all et 999 visent le tout-GPU.
|
||||
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
|
||||
if ngl == "" {
|
||||
ngl = strings.TrimSpace(cfg["NGL"])
|
||||
if strings.EqualFold(ngl, "auto") {
|
||||
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
|
||||
}
|
||||
}
|
||||
if ngl = strings.ToLower(strings.TrimSpace(ngl)); ngl != "" && ngl != "auto" && ngl != "all" && ngl != "999" {
|
||||
return "couches GPU limitées à " + ngl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// tensorOverride dit si des tenseurs sont placés à la main (-ot et ses formes
|
||||
// déguisées --cpu-moe, --n-cpu-moe, --n-cpu-ffn), en drapeau ou en variable.
|
||||
// Vide = aucun. Le pipeline entre cartes s'y arrête, et --fit aussi
|
||||
// (« tensor_buft_overrides already set by user, abort »).
|
||||
func tensorOverride(extra []string, argEnv map[string]string) string {
|
||||
if hasAnyFlag(extra, "-ot", "--override-tensor", "--cpu-moe", "-cmoe") ||
|
||||
argEnv["LLAMA_ARG_OVERRIDE_TENSOR"] != "" || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
|
||||
return "surcharge de tenseurs"
|
||||
}
|
||||
for _, n := range []struct{ env, short, long string }{
|
||||
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
|
||||
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
|
||||
} {
|
||||
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
|
||||
if v = strings.TrimSpace(v); v != "" && v != "0" {
|
||||
return "couches " + n.long + " sur CPU"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// kvOffloaded : le cache KV reste-t-il sur GPU ? Le dernier -kvo/-nkvo de la
|
||||
// ligne de commande tranche ; sinon LLAMA_ARG_NO_KV_OFFLOAD, dont la SEULE
|
||||
// présence vaut « non » pour llama.cpp (quelle que soit sa valeur), puis
|
||||
// LLAMA_ARG_KV_OFFLOAD lu comme un booléen.
|
||||
func kvOffloaded(extra []string, argEnv map[string]string) bool {
|
||||
on, set := true, false
|
||||
for _, a := range extra {
|
||||
name, _, _ := strings.Cut(a, "=")
|
||||
switch name {
|
||||
case "-kvo", "--kv-offload":
|
||||
on, set = true, true
|
||||
case "-nkvo", "--no-kv-offload":
|
||||
on, set = false, true
|
||||
}
|
||||
}
|
||||
if set {
|
||||
return on
|
||||
}
|
||||
if argEnv["LLAMA_ARG_NO_KV_OFFLOAD"] != "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(argEnv["LLAMA_ARG_KV_OFFLOAD"])) {
|
||||
case "off", "disabled", "false", "0":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// envTruthy lit un drapeau sans valeur passé par variable, comme llama.cpp :
|
||||
// seuls on, enabled, true et 1 l'activent.
|
||||
func envTruthy(v string) bool {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "on", "enabled", "true", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// flagValues : les valeurs de TOUTES les occurrences d'un drapeau (« -x 1 » ou
|
||||
// « -x=1 »), pour ceux que llama.cpp cumule au lieu de garder le dernier.
|
||||
func flagValues(args []string, flags ...string) []string {
|
||||
var vals []string
|
||||
for i, a := range args {
|
||||
name, val, hasEq := strings.Cut(a, "=")
|
||||
for _, f := range flags {
|
||||
if name != f {
|
||||
continue
|
||||
}
|
||||
if hasEq {
|
||||
vals = append(vals, val)
|
||||
} else if i+1 < len(args) {
|
||||
vals = append(vals, args[i+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// probeServeGPUs remplit ce dont launchQueuesEnv a besoin. On ne sonde
|
||||
// nvidia-smi que si la réponse peut servir : variable déjà posée, « off », un
|
||||
// --device explicite ou CUDA_VISIBLE_DEVICES suffisent à trancher sans lui.
|
||||
func probeServeGPUs(cfg map[string]string, extra []string, si *serveSysInfo) {
|
||||
si.LaunchQueues = os.Getenv("CUDA_SCALE_LAUNCH_QUEUES")
|
||||
si.ArgEnv = map[string]string{}
|
||||
for _, k := range serveArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
si.ArgEnv[k] = v
|
||||
} else if ok && k == "LLAMA_ARG_NO_KV_OFFLOAD" {
|
||||
si.ArgEnv[k] = "1" // présente mais vide : llama.cpp n'y lit que la présence
|
||||
}
|
||||
}
|
||||
if si.LaunchQueues != "" || strings.EqualFold(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]), "off") {
|
||||
return
|
||||
}
|
||||
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
|
||||
return
|
||||
}
|
||||
// cmdServe a déjà posé la sélection de config.env dans l'environnement :
|
||||
// os.Getenv voit donc le preset d'abord, le conteneur ensuite.
|
||||
if cvd := strings.TrimSpace(os.Getenv("CUDA_VISIBLE_DEVICES")); cvd != "" {
|
||||
for _, d := range strings.Split(cvd, ",") {
|
||||
if strings.TrimSpace(d) != "" {
|
||||
si.GPUs++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
si.GPUs = nvidiaGPUCount(3 * time.Second)
|
||||
}
|
||||
|
||||
// nvidiaGPUCount compte les cartes NVIDIA. Borné dans le temps : un pilote
|
||||
// coincé peut faire pendre nvidia-smi, et le démarrage du moteur ne doit pas
|
||||
// en dépendre. Au moindre doute, 0 — on ne pose rien.
|
||||
func nvidiaGPUCount(timeout time.Duration) int {
|
||||
if !hasTool("nvidia-smi") {
|
||||
return 0
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index", "--format=csv,noheader")).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLaunchQueuesEnv : CUDA_SCALE_LAUNCH_QUEUES n'est posé que là où le
|
||||
// pipeline entre cartes peut exister, et jamais par-dessus un choix de
|
||||
// l'utilisateur.
|
||||
func TestLaunchQueuesEnv(t *testing.T) {
|
||||
two := serveSysInfo{GPUs: 2}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want string
|
||||
wantNotes int
|
||||
}{
|
||||
{name: "2 GPU, dense tout sur GPU : 4x, et on le dit",
|
||||
cfg: map[string]string{"NGL": "999"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "NGL=all ou auto : pipeline possible",
|
||||
cfg: map[string]string{"NGL": "all"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "1 GPU : rien",
|
||||
cfg: map[string]string{"NGL": "999"}, si: serveSysInfo{GPUs: 1}},
|
||||
{name: "nombre de GPU inconnu : rien",
|
||||
cfg: map[string]string{}},
|
||||
{name: "variable déjà posée par l'utilisateur : intouchée",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2x"}, si: serveSysInfo{GPUs: 2, LaunchQueues: "1x"}},
|
||||
{name: "CUDA_LAUNCH_QUEUES=off : rien",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "off"}, si: two},
|
||||
{name: "valeur explicite valide : posée telle quelle, même sur 1 GPU",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2X"}, si: serveSysInfo{GPUs: 1}, want: "2x", wantNotes: 1},
|
||||
{name: "valeur invalide : ignorée, dite, puis automatique",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "8x"}, si: two, want: "4x", wantNotes: 2},
|
||||
{name: "valeur invalide sur 1 GPU : seulement la note",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "beaucoup"}, si: serveSysInfo{GPUs: 1}, wantNotes: 1},
|
||||
{name: "preset MoE (-ot, --n-cpu-moe) sur 2 GPU : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`}, si: two},
|
||||
{name: "--n-cpu-moe seul : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe=12"}, si: two},
|
||||
{name: "--n-cpu-moe 0 : aucun expert sur CPU, pipeline possible",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "--cpu-moe : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--cpu-moe"}, si: two},
|
||||
{name: "-sm row : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-sm row"}, si: two},
|
||||
{name: "--split-mode=layer explicite : 4x",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--split-mode=layer"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "NGL=28 : des couches restent au CPU, rien",
|
||||
cfg: map[string]string{"NGL": "28"}, si: two},
|
||||
{name: "-ngl 40 dans EXTRA_ARGS l'emporte sur NGL=999 : rien",
|
||||
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "-ngl 40"}, si: two},
|
||||
{name: "cache KV sur CPU : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-nkvo"}, si: two},
|
||||
{name: "--device d'une seule carte : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA0"}, si: two},
|
||||
{name: "--device de deux cartes CUDA, nvidia-smi non sondé : 4x",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA1,CUDA0 --tensor-split 0.6,0.4"}, want: "4x", wantNotes: 1},
|
||||
{name: "--device Vulkan : la variable ne concerne pas ce moteur",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-dev Vulkan0,Vulkan1"}, si: two},
|
||||
{name: "--device none : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-dev none"}, si: two},
|
||||
{name: "LLAMA_ARG_N_CPU_MOE dans l'environnement du moteur : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
|
||||
{name: "LLAMA_ARG_SPLIT_MODE=none : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "none"}}},
|
||||
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=20 : rien",
|
||||
cfg: map[string]string{"NGL": "auto"}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}},
|
||||
{name: "LLAMA_ARG_CPU_MOE=0 : désactivé, pipeline possible",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "0"}}, want: "4x", wantNotes: 1},
|
||||
{name: "LLAMA_ARG_DEVICE d'une seule carte : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_DEVICE": "CUDA1"}}},
|
||||
// Relecture : ce que llama.cpp fait vraiment de ses variables.
|
||||
{name: "--n-cpu-moe 0 n'annule pas LLAMA_ARG_N_CPU_MOE (surcharges cumulées) : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"},
|
||||
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
|
||||
{name: "deux --n-cpu-moe, le dernier à 0 : le premier compte quand même, rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 20 --n-cpu-moe 0"}, si: two},
|
||||
{name: "--n-cpu-ffn (FFN dense sur CPU) : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-ncffn 8"}, si: two},
|
||||
{name: "LLAMA_ARG_OVERRIDE_TENSOR : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_OVERRIDE_TENSOR": "exps=CPU"}}},
|
||||
{name: "LLAMA_ARG_CPU_MOE=yes : llama.cpp ne l'active pas, 4x",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "yes"}}, want: "4x", wantNotes: 1},
|
||||
{name: "LLAMA_ARG_NO_KV_OFFLOAD=0 : sa seule présence coupe l'offload, rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "0"}}},
|
||||
{name: "LLAMA_ARG_KV_OFFLOAD=false : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_KV_OFFLOAD": "false"}}},
|
||||
{name: "-kvo sur la ligne l'emporte sur LLAMA_ARG_NO_KV_OFFLOAD : 4x",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-kvo"},
|
||||
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "1"}}, want: "4x", wantNotes: 1},
|
||||
{name: "-nkvo puis -kvo : le dernier gagne, 4x",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-nkvo -kvo"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "NGL absent : Loki passe -ngl auto, LLAMA_ARG_N_GPU_LAYERS ne compte pas, 4x",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}, want: "4x", wantNotes: 1},
|
||||
{name: "NGL=Auto (casse libre, comme nglArgs) : 4x",
|
||||
cfg: map[string]string{"NGL": "Auto"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "-sm tensor : pas de pipeline par couches, rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-sm tensor"}, si: two},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, notes := launchQueuesEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
|
||||
if got != c.want {
|
||||
t.Fatalf("valeur = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// La variable passe par l'environnement : la ligne de commande ne bouge pas
|
||||
// d'un octet, et la sélection GPU l'accompagne.
|
||||
func TestBuildServeArgsLaunchQueues(t *testing.T) {
|
||||
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
|
||||
cfg := map[string]string{"NGL": "999", "CUDA_VISIBLE_DEVICES": "1,0"}
|
||||
si := serveSysInfo{Help: helpRecent, Model: model}
|
||||
without, _, _ := buildServeArgs(cfg, nil, bin, si)
|
||||
si.GPUs = 2
|
||||
args, env, notes := buildServeArgs(cfg, nil, bin, si)
|
||||
if !reflect.DeepEqual(args, without) {
|
||||
t.Fatalf("la ligne de commande a changé\n got %q\nwant %q", args, without)
|
||||
}
|
||||
want := map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID",
|
||||
"CUDA_SCALE_LAUNCH_QUEUES": "4x"}
|
||||
if !reflect.DeepEqual(env, want) {
|
||||
t.Fatalf("env = %v, want %v", env, want)
|
||||
}
|
||||
// Une note pour 999 → auto, une pour les files de lancement.
|
||||
if len(notes) != 2 {
|
||||
t.Fatalf("notes = %q", notes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// Décodage spéculatif géré par Loki : une tête MTP (prédiction de plusieurs
|
||||
// jetons) intégrée au modèle ou publiée à part, ou un petit modèle brouillon.
|
||||
//
|
||||
// SPEC=off (défaut) → rien, même avec MODEL_DRAFT
|
||||
// SPEC=auto → seulement si tout est réuni (voir specAutoBlocker)
|
||||
// SPEC=mtp → imposé, sans les garde-fous d'auto, avec un avertissement
|
||||
// MODEL_DRAFT=<nom> → la tête ou le brouillon, résolu comme MMPROJ
|
||||
// SPEC_N_MAX=<n> → --spec-draft-n-max (défaut du moteur : 3)
|
||||
// SPEC_SAMPLING=probabilistic → seulement avec SPEC=mtp ; greedy sinon
|
||||
//
|
||||
// Rien n'y touche au modèle : chaque jeton émis est tiré par l'échantillonneur
|
||||
// du modèle cible, un jeton du brouillon n'est gardé que s'il coïncide
|
||||
// (common_sampler_sample_and_accept_n). Le prix est ailleurs : 1 à 2 Go de VRAM
|
||||
// en plus, un prefill parfois plus lent, et une fonction très récente qui peut
|
||||
// empêcher le moteur de démarrer. D'où l'opt-in, les garde-fous d'auto et le
|
||||
// jeton de tentative (specAutoVerdict) : un lancement automatique qui n'a
|
||||
// jamais répondu ne se retente pas avec la même configuration.
|
||||
|
||||
const (
|
||||
// specAutoMinBuild : premier build officiel où Loki ose l'auto (MTP serveur,
|
||||
// PR #22673 et suites). Build inconnu = non : un compilé maison ou un fork
|
||||
// peut connaître le drapeau sans le tenir.
|
||||
specAutoMinBuild = 11009
|
||||
specNMaxLimit = 64
|
||||
)
|
||||
|
||||
// specArgEnv : les variables qui règlent la spéculation sans drapeau. Posées,
|
||||
// elles valent un réglage d'EXTRA_ARGS : Loki se tait.
|
||||
var specArgEnv = []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO",
|
||||
"LLAMA_ARG_SPEC_DRAFT_N_MAX", "LLAMA_ARG_SPEC_DRAFT_SAMPLING"}
|
||||
|
||||
// specUserFlags : la spéculation réglée à la main. Les ajouter EN PLUS ferait
|
||||
// cumuler les types (--spec-type s'additionne) ou charger deux brouillons.
|
||||
var specUserFlags = []string{"--spec-type", "-md", "--model-draft", "--spec-draft-model",
|
||||
"-hfd", "-hfrd", "--spec-draft-hf", "--hf-repo-draft", "--spec-default"}
|
||||
|
||||
// probeSpec : variables du moteur, puis MODEL_DRAFT résolu comme MMPROJ (nom
|
||||
// simple cherché dans les dossiers déclarés, ou chemin absolu). Introuvable ne
|
||||
// bloque PAS le lancement, contrairement au projecteur : le brouillon n'est
|
||||
// qu'une accélération, le moteur démarre sans et la note dit pourquoi.
|
||||
func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range specArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
d := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||
if d == "" {
|
||||
return
|
||||
}
|
||||
p, err := resolveServeModelPath(d)
|
||||
if err == nil {
|
||||
_, err = os.Stat(p)
|
||||
}
|
||||
if err != nil {
|
||||
si.DraftErr = err.Error()
|
||||
return
|
||||
}
|
||||
si.Draft = p
|
||||
if g, err := ggufMeta(p); err == nil {
|
||||
si.DraftGGUF = &g
|
||||
}
|
||||
}
|
||||
|
||||
// specSidecarArch : têtes de spéculation qui ne sont pas des modèles autonomes
|
||||
// (llama.cpp : draft-eagle3, draft-dflash/dspark). Un -md vers elles avec
|
||||
// draft-simple ne démarrerait pas : leur type se règle à la main.
|
||||
func specSidecarArch(arch string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(arch)) {
|
||||
case "eagle3", "dflash":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// specMode lit SPEC : "off", "auto", "mtp", ou "" si illisible.
|
||||
func specMode(cfg map[string]string) string {
|
||||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC"])); v {
|
||||
case "", "off", "non", "no", "0":
|
||||
return "off"
|
||||
case "auto", "mtp":
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// specUserSet : EXTRA_ARGS ou l'environnement règlent déjà la spéculation.
|
||||
func specUserSet(extra []string, argEnv map[string]string) bool {
|
||||
if hasAnyFlag(extra, specUserFlags...) {
|
||||
return true
|
||||
}
|
||||
for _, k := range []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO"} {
|
||||
if argEnv[k] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// helpSupportsMTP : le moteur connaît le type draft-mtp ET le drapeau actuel
|
||||
// du nombre de jetons — un moteur qui n'a que l'ancien --draft-max est d'une
|
||||
// autre génération, où un MTP n'existe pas.
|
||||
func helpSupportsMTP(help string) bool {
|
||||
return strings.Contains(help, "draft-mtp") && strings.Contains(help, "--spec-draft-n-max")
|
||||
}
|
||||
|
||||
// ctxFixed : le contexte que verra le moteur (EXTRA_ARGS, sinon CTX, sinon le
|
||||
// défaut de buildServeArgs), et s'il est chiffré. Avec 0 ou une valeur
|
||||
// illisible, --fit a le droit de RÉDUIRE le contexte pour tenir.
|
||||
func ctxFixed(cfg map[string]string, extra []string) (string, bool) {
|
||||
c := flagValue(extra, "-c", "--ctx-size")
|
||||
if c == "" {
|
||||
c = strings.TrimSpace(cfg["CTX"])
|
||||
}
|
||||
if c == "" {
|
||||
c = "32768"
|
||||
}
|
||||
n, err := strconv.Atoi(c)
|
||||
return c, err == nil && n > 0
|
||||
}
|
||||
|
||||
// statefulSampler : un échantillonneur dont l'état avance à chaque tirage
|
||||
// (mirostat, adaptive-p). Le rejet probabiliste fait d'abord tirer le modèle
|
||||
// cible, puis peut émettre un autre jeton : ces échantillonneurs avanceraient
|
||||
// sur un jeton que la réponse ne contient pas. Le mode greedy, lui, reste exact.
|
||||
func statefulSampler(extra []string) string {
|
||||
for _, v := range flagValues(extra, "--mirostat") {
|
||||
if v = strings.TrimSpace(v); v != "" && v != "0" {
|
||||
return "--mirostat " + v
|
||||
}
|
||||
}
|
||||
for _, v := range flagValues(extra, "--samplers") {
|
||||
if strings.Contains(strings.ToLower(strings.ReplaceAll(v, "-", "_")), "adaptive_p") {
|
||||
return "--samplers adaptive-p"
|
||||
}
|
||||
}
|
||||
for _, v := range flagValues(extra, "--sampling-seq", "--sampler-seq") {
|
||||
if strings.Contains(v, "a") {
|
||||
return "--sampling-seq avec adaptive-p"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// specAutoBlocker dit pourquoi SPEC=auto ne s'active pas, ou vide. Chaque
|
||||
// garde répond à une panne précise :
|
||||
//
|
||||
// - qwen4exp : MTP fusionné il y a quelques jours, OOM signalés en multi-GPU ;
|
||||
// - placement manuel (couches fixées, -ts, -ot, experts sur CPU, -sm row,
|
||||
// --fit off, -dev) : seul --fit compte la VRAM de la tête MTP ;
|
||||
// - contexte non chiffré : fit aurait le droit de le RÉDUIRE pour faire de
|
||||
// la place — de l'information perdue pour le modèle ;
|
||||
// - projecteur vision : une VRAM déjà serrée par un second modèle ;
|
||||
// - build officiel inconnu ou trop ancien ;
|
||||
// - un essai précédent avec la même configuration n'a jamais répondu.
|
||||
func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
|
||||
if si.GGUF != nil && strings.EqualFold(si.GGUF.Arch, "qwen4exp") {
|
||||
return "qwen4exp : MTP trop récent pour ce moteur, SPEC=mtp pour l'imposer"
|
||||
}
|
||||
if why := fitBlocker(cfg, extra, si); why != "" {
|
||||
return "placement manuel (" + why + ") — --fit ne compte pas la VRAM du brouillon"
|
||||
}
|
||||
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
|
||||
return "placement manuel (--device) — --fit ne compte pas la VRAM du brouillon"
|
||||
}
|
||||
if _, ok := ctxFixed(cfg, extra); !ok {
|
||||
return "contexte non chiffré — fit pourrait le réduire pour loger le brouillon"
|
||||
}
|
||||
if si.MMProj != "" || hasAnyFlag(extra, "--mmproj", "-mm") {
|
||||
return "projecteur vision chargé"
|
||||
}
|
||||
if si.EngineBuild < specAutoMinBuild {
|
||||
if si.EngineBuild <= 0 {
|
||||
return "build du moteur inconnu (compilé ou fork)"
|
||||
}
|
||||
return fmt.Sprintf("moteur b%d, b%d au moins", si.EngineBuild, specAutoMinBuild)
|
||||
}
|
||||
return si.SpecAutoBlocked
|
||||
}
|
||||
|
||||
// specArgs : drapeaux de décodage spéculatif que Loki ajoute, ce qu'il en dit,
|
||||
// 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) {
|
||||
mode := specMode(cfg)
|
||||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||
switch mode {
|
||||
case "":
|
||||
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto ou mtp) : sans décodage spéculatif"}, false
|
||||
case "off":
|
||||
if draftKey != "" {
|
||||
return nil, []string{"MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)"}, false
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
if specUserSet(extra, si.ArgEnv) {
|
||||
return nil, []string{"décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré"}, false
|
||||
}
|
||||
skip := func(why string) ([]string, []string, bool) {
|
||||
return nil, []string{"SPEC=" + mode + " : " + why + " — sans décodage spéculatif"}, false
|
||||
}
|
||||
|
||||
// La source : MODEL_DRAFT s'il est posé, sinon la tête MTP du modèle. Dans
|
||||
// les deux cas, on regarde les TENSEURS, comme llama.cpp : une clé
|
||||
// nextn_predict_layers seule ne prouve pas que la tête est dans le fichier.
|
||||
mtp := false
|
||||
switch {
|
||||
case draftKey != "":
|
||||
switch {
|
||||
case si.Draft == "":
|
||||
why := "MODEL_DRAFT=" + draftKey + " introuvable"
|
||||
if si.DraftErr != "" {
|
||||
why += " (" + si.DraftErr + ")"
|
||||
}
|
||||
return skip(why)
|
||||
case si.DraftGGUF == nil:
|
||||
return skip("MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)")
|
||||
case si.DraftGGUF.HasNextNTensor:
|
||||
if !helpSupportsMTP(si.Help) {
|
||||
return skip("ce moteur ne connaît pas draft-mtp")
|
||||
}
|
||||
// Type explicite : llama.cpp ne le devine que sur la première tranche.
|
||||
args, mtp = []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true
|
||||
case specSidecarArch(si.DraftGGUF.Arch):
|
||||
return skip("MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
|
||||
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS")
|
||||
default:
|
||||
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
|
||||
return skip("ce moteur ne connaît pas --spec-type draft-simple")
|
||||
}
|
||||
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
|
||||
// VRAM puis jamais utilisé.
|
||||
args = []string{"-md", si.Draft, "--spec-type", "draft-simple"}
|
||||
}
|
||||
case si.GGUF == nil:
|
||||
return skip("métadonnées du modèle illisibles")
|
||||
case !si.GGUF.HasNextNTensor:
|
||||
if mode == "mtp" || si.GGUF.NextN > 0 {
|
||||
return skip("pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)")
|
||||
}
|
||||
return nil, nil, false // auto sur un modèle sans MTP : rien à dire
|
||||
case !helpSupportsMTP(si.Help):
|
||||
return skip("ce moteur ne connaît pas draft-mtp")
|
||||
default:
|
||||
args, mtp = []string{"--spec-type", "draft-mtp"}, true
|
||||
}
|
||||
|
||||
label := "brouillon " + baseName(si.Draft)
|
||||
if mtp {
|
||||
label = "tête MTP"
|
||||
}
|
||||
if mode == "auto" {
|
||||
if why := specAutoBlocker(cfg, extra, si); why != "" {
|
||||
return skip(why)
|
||||
}
|
||||
auto = true
|
||||
notes = append(notes, "SPEC=auto → "+label+" : sortie inchangée (chaque jeton est vérifié par le modèle), "+
|
||||
"~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper.")
|
||||
} else {
|
||||
note := "SPEC=mtp → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
|
||||
if why := fitBlocker(cfg, extra, si); why != "" || hasAnyFlag(extra, "-dev", "--device") {
|
||||
if why == "" {
|
||||
why = "--device"
|
||||
}
|
||||
note += " ; placement manuel (" + why + ") : --fit ne compte pas le brouillon, " +
|
||||
"place-le avec -devd / -ngld si la VRAM manque"
|
||||
}
|
||||
if _, ok := ctxFixed(cfg, extra); !ok {
|
||||
note += " ; contexte non chiffré : fit pourrait le réduire pour loger le brouillon"
|
||||
}
|
||||
if si.GGUF != nil && strings.EqualFold(si.GGUF.Arch, "qwen4exp") {
|
||||
note += " ; MTP qwen4exp très récent"
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
|
||||
// Nombre de jetons anticipés : défaut du moteur (3) si la clé est vide.
|
||||
if v := strings.TrimSpace(cfg["SPEC_N_MAX"]); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
switch {
|
||||
case err != nil || n < 1 || n > specNMaxLimit:
|
||||
notes = append(notes, "SPEC_N_MAX="+v+" illisible (1 à 64 jetons) : défaut du moteur")
|
||||
case !strings.Contains(si.Help, "--spec-draft-n-max"):
|
||||
notes = append(notes, "ce moteur ne connaît pas --spec-draft-n-max : SPEC_N_MAX ignoré")
|
||||
case hasAnyFlag(extra, "--spec-draft-n-max") || si.ArgEnv["LLAMA_ARG_SPEC_DRAFT_N_MAX"] != "":
|
||||
default:
|
||||
args = append(args, "--spec-draft-n-max", strconv.Itoa(n))
|
||||
}
|
||||
}
|
||||
|
||||
// Tirage du brouillon. greedy est exact quelle que soit la chaîne
|
||||
// d'échantillonnage : on le fixe dès que le moteur connaît le drapeau, pour
|
||||
// qu'un changement de défaut en amont ne change rien ici.
|
||||
sampling := "greedy"
|
||||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC_SAMPLING"])); v {
|
||||
case "", "greedy":
|
||||
case "probabilistic":
|
||||
switch {
|
||||
case mode != "mtp":
|
||||
notes = append(notes, "SPEC_SAMPLING=probabilistic ignoré avec SPEC=auto (SPEC=mtp pour le choisir) : greedy")
|
||||
case statefulSampler(extra) != "":
|
||||
notes = append(notes, "SPEC_SAMPLING=probabilistic refusé avec "+statefulSampler(extra)+
|
||||
" (cet échantillonneur avancerait sur des jetons rejetés) : greedy")
|
||||
case !strings.Contains(si.Help, "probabilistic"):
|
||||
notes = append(notes, "ce moteur ne connaît pas --spec-draft-sampling probabilistic : greedy")
|
||||
default:
|
||||
sampling = v
|
||||
}
|
||||
default:
|
||||
notes = append(notes, "SPEC_SAMPLING="+v+" illisible (greedy ou probabilistic) : greedy")
|
||||
}
|
||||
if strings.Contains(si.Help, "--spec-draft-sampling") && !hasAnyFlag(extra, "--spec-draft-sampling") &&
|
||||
si.ArgEnv["LLAMA_ARG_SPEC_DRAFT_SAMPLING"] == "" {
|
||||
args = append(args, "--spec-draft-sampling", sampling)
|
||||
}
|
||||
return args, notes, auto
|
||||
}
|
||||
|
||||
// --- Jeton de tentative ------------------------------------------------------
|
||||
//
|
||||
// Un essai automatique qui fait tomber le moteur ne doit pas se rejouer : sous
|
||||
// systemd il redémarrerait en boucle en rechargeant 15 Go à chaque fois. cmdServe
|
||||
// ne peut pas constater l'échec lui-même (sous Unix il DEVIENT llama-server).
|
||||
// Il pose donc un jeton avant de lancer ; le process web l'efface dès que le
|
||||
// moteur répond. Au lancement suivant, un jeton encore là pour la même
|
||||
// configuration, le même moteur et le même build veut dire « jamais répondu » :
|
||||
// l'auto est coupé pour cette combinaison, et on le dit. Une mise à jour du
|
||||
// moteur ou un preset modifié retente. Sans process web (CLI seule), le jeton
|
||||
// n'est jamais effacé : on retombe sur off, le côté sûr.
|
||||
|
||||
const (
|
||||
specAttemptKey = "spec_auto_attempt"
|
||||
specFailedKey = "spec_auto_failed"
|
||||
specFailedMax = 32
|
||||
)
|
||||
|
||||
// specAutoMark identifie une combinaison preset × moteur.
|
||||
type specAutoMark struct {
|
||||
FP string `json:"fp"`
|
||||
Bin string `json:"bin"`
|
||||
Build int `json:"build"`
|
||||
}
|
||||
|
||||
func (m specAutoMark) key() string { return fmt.Sprintf("%s|%s|%d", m.FP, m.Bin, m.Build) }
|
||||
|
||||
// specAutoVerdict : pure. Renvoie la raison de couper l'auto (vide = permis)
|
||||
// et s'il faut inscrire cur parmi les échecs.
|
||||
func specAutoVerdict(cur specAutoMark, attempt *specAutoMark, failed map[string]string) (why string, record bool) {
|
||||
if w := failed[cur.key()]; w != "" {
|
||||
return w, false
|
||||
}
|
||||
if attempt != nil && attempt.key() == cur.key() {
|
||||
return "le dernier lancement avec cette configuration n'a jamais répondu", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// specAutoPeek lit l'état sans rien changer : la raison de couper l'auto (vide
|
||||
// = permis) et s'il faudra inscrire l'échec. Rien n'est consommé avant que le
|
||||
// port soit libre (specAutoSettle) : un second « loki serve », refusé parce que
|
||||
// le premier charge encore, ne doit pas prendre le jeton de celui-ci pour un
|
||||
// échec.
|
||||
func specAutoPeek(cur specAutoMark) (why string, record bool) {
|
||||
_ = view(bkState, func(b *bolt.Bucket) error {
|
||||
attempt, failed := readSpecAuto(b)
|
||||
why, record = specAutoVerdict(cur, attempt, failed)
|
||||
return nil
|
||||
})
|
||||
return why, record
|
||||
}
|
||||
|
||||
// specAutoSettle range, port libre, juste avant de lancer le moteur : l'ancien
|
||||
// jeton est consommé, l'échec inscrit s'il y a lieu (record, why), et un
|
||||
// nouveau jeton posé si ce lancement ajoute des drapeaux automatiques.
|
||||
func specAutoSettle(cur specAutoMark, why string, record, attempt bool) {
|
||||
_ = update(bkState, func(b *bolt.Bucket) error {
|
||||
_, failed := readSpecAuto(b)
|
||||
_ = b.Delete([]byte(specAttemptKey))
|
||||
if record {
|
||||
if err := putFailed(b, failed, cur.key(), why); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !attempt {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(cur)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(specAttemptKey), raw)
|
||||
})
|
||||
}
|
||||
|
||||
// readSpecAuto : le jeton en cours (nil = aucun) et les échecs inscrits.
|
||||
func readSpecAuto(b *bolt.Bucket) (attempt *specAutoMark, failed map[string]string) {
|
||||
if raw := b.Get([]byte(specAttemptKey)); raw != nil {
|
||||
var a specAutoMark
|
||||
if json.Unmarshal(raw, &a) == nil {
|
||||
attempt = &a
|
||||
}
|
||||
}
|
||||
failed = map[string]string{}
|
||||
if raw := b.Get([]byte(specFailedKey)); raw != nil {
|
||||
_ = json.Unmarshal(raw, &failed)
|
||||
}
|
||||
return attempt, failed
|
||||
}
|
||||
|
||||
func putFailed(b *bolt.Bucket, failed map[string]string, key, why string) error {
|
||||
if len(failed) >= specFailedMax {
|
||||
failed = map[string]string{}
|
||||
}
|
||||
failed[key] = why
|
||||
raw, err := json.Marshal(failed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(specFailedKey), raw)
|
||||
}
|
||||
|
||||
// --- Côté process web --------------------------------------------------------
|
||||
|
||||
var (
|
||||
specWatchOnce sync.Once
|
||||
offloadedRe = regexp.MustCompile(`offloaded (\d+)/(\d+) layers to GPU`)
|
||||
)
|
||||
|
||||
// startSpecAttemptWatch : une goroutine qui efface le jeton dès que le moteur
|
||||
// répond, sans attendre qu'une page interroge /api/status.
|
||||
func startSpecAttemptWatch() {
|
||||
specWatchOnce.Do(func() {
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
specAttemptTick()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func specAttemptTick() {
|
||||
var a specAutoMark
|
||||
if !getJSON(bkState, specAttemptKey, &a) {
|
||||
return
|
||||
}
|
||||
if externalActive() {
|
||||
specAttemptClear(a, "") // plus de moteur local à attendre
|
||||
return
|
||||
}
|
||||
if !healthCheck() {
|
||||
return
|
||||
}
|
||||
// Répondu. Mais avec -c fixé, fit ne réduit pas le contexte : s'il manque
|
||||
// la place du brouillon, il descend des couches en RAM — le moteur tourne,
|
||||
// plus lentement que sans MTP. Ça compte aussi comme un échec de l'auto.
|
||||
why := ""
|
||||
if n, m := lastOffload(serviceLogTail(600)); m > 0 && n < m {
|
||||
why = fmt.Sprintf("seulement %d/%d couches sur GPU avec le brouillon : fit a déplacé des couches en RAM", n, m)
|
||||
fmt.Println("[loki] SPEC=auto : " + why + " — coupé au prochain démarrage (SPEC=mtp pour l'imposer)")
|
||||
}
|
||||
specAttemptClear(a, why)
|
||||
}
|
||||
|
||||
// specAttemptClear efface le jeton a — et lui seul : relu sous verrou, car un
|
||||
// « loki serve » a pu entre-temps poser celui d'un AUTRE lancement, qui n'a pas
|
||||
// encore répondu. why non vide inscrit l'échec de a.
|
||||
func specAttemptClear(a specAutoMark, why string) {
|
||||
_ = update(bkState, func(b *bolt.Bucket) error {
|
||||
cur, failed := readSpecAuto(b)
|
||||
if cur == nil || cur.key() != a.key() {
|
||||
return nil
|
||||
}
|
||||
_ = b.Delete([]byte(specAttemptKey))
|
||||
if why == "" {
|
||||
return nil
|
||||
}
|
||||
return putFailed(b, failed, a.key(), why)
|
||||
})
|
||||
}
|
||||
|
||||
// lastOffload lit les lignes « offloaded N/M layers to GPU » du DERNIER
|
||||
// chargement du journal, repéré à la ligne du serveur « loading model
|
||||
// '<chemin>' ». Pas à « load_model » (le serveur écrit encore « load_model:
|
||||
// initializing… » APRÈS le chargement) ni à « loading model tensors » (écrit
|
||||
// aussi pour le brouillon, qui masquerait le modèle). Un brouillon chargé à
|
||||
// part a sa propre ligne : la pire des deux compte. 0, 0 = introuvable : on ne
|
||||
// conclut rien.
|
||||
func lastOffload(log string) (n, m int) {
|
||||
lines := strings.Split(log, "\n")
|
||||
start := -1
|
||||
for i, l := range lines {
|
||||
if strings.Contains(l, "loading model '") {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return 0, 0
|
||||
}
|
||||
for _, l := range lines[start:] {
|
||||
s := offloadedRe.FindStringSubmatch(l)
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
a, _ := strconv.Atoi(s[1])
|
||||
b, _ := strconv.Atoi(s[2])
|
||||
if m == 0 || a < b {
|
||||
n, m = a, b
|
||||
}
|
||||
if a < b {
|
||||
break
|
||||
}
|
||||
}
|
||||
return n, m
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const helpSpec = helpRecent + `--spec-draft-n-max N number of tokens to draft for speculative decoding (default: 3)
|
||||
--spec-draft-sampling {greedy,probabilistic}
|
||||
how the draft is sampled (default: greedy)
|
||||
--spec-type [none,ngram-cache,ngram-simple,ngram-map-k,ngram-map-k4v,ngram-mod,draft-simple,draft-eagle3,draft-mtp,draft-dflash,draft-dspark]
|
||||
comma-separated list of types of speculative decoding to use (default: none)
|
||||
`
|
||||
|
||||
// helpSpecOld : un moteur qui connaît --spec-type mais pas encore le MTP.
|
||||
const helpSpecOld = helpRecent + `--spec-type [none,ngram-cache,ngram-simple,draft-simple]
|
||||
`
|
||||
|
||||
// mtpReady : un Qwen3.6 27B avec sa tête MTP dans le fichier, placé par --fit,
|
||||
// sur un moteur officiel récent. Le cas où SPEC=auto a le droit d'agir.
|
||||
func mtpReady() serveSysInfo {
|
||||
return serveSysInfo{
|
||||
Help: helpSpec,
|
||||
Model: "/models/Qwen3.6-27B-MTP.gguf",
|
||||
GGUF: &GGUFInfo{Arch: "qwen35", BlockCount: 65, NextN: 1, HasNextNTensor: true, Hybrid: true},
|
||||
ArgEnv: map[string]string{},
|
||||
EngineBuild: 11351,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecArgs(t *testing.T) {
|
||||
mtp := []string{"--spec-type", "draft-mtp", "--spec-draft-sampling", "greedy"}
|
||||
auto := map[string]string{"SPEC": "auto"}
|
||||
forced := map[string]string{"SPEC": "mtp"}
|
||||
with := func(base map[string]string, kv ...string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for k, v := range base {
|
||||
m[k] = v
|
||||
}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i]] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
wantAuto bool
|
||||
notes int
|
||||
}{
|
||||
{name: "SPEC absent : rien, même sur un modèle MTP (opt-in)", cfg: map[string]string{}},
|
||||
{name: "SPEC=off : rien", cfg: map[string]string{"SPEC": "off"}},
|
||||
{name: "SPEC=off avec MODEL_DRAFT : rien, et on le dit",
|
||||
cfg: map[string]string{"SPEC": "off", "MODEL_DRAFT": "mtp-Qwen3.6-27B.gguf"}, notes: 1},
|
||||
{name: "SPEC illisible : rien, et on le dit", cfg: map[string]string{"SPEC": "turbo"}, notes: 1},
|
||||
{name: "auto, tête MTP dans le fichier, --fit actif : draft-mtp", cfg: auto, want: mtp, wantAuto: true, notes: 1},
|
||||
{name: "auto, clé nextn sans tenseur (tête publiée à part) : rien",
|
||||
cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF.HasNextNTensor = false }},
|
||||
{name: "auto, modèle sans MTP : rien, sans bruit",
|
||||
cfg: auto, si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "llama", BlockCount: 32} }},
|
||||
{name: "auto, métadonnées illisibles : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||||
{name: "--spec-type dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--spec-type", "ngram-mod"}, notes: 1},
|
||||
{name: "--model-draft=… dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--model-draft=d.gguf"}, notes: 1},
|
||||
{name: "-hfd dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"-hfd", "u/r"}, notes: 1},
|
||||
{name: "--spec-default dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--spec-default"}, notes: 1},
|
||||
{name: "LLAMA_ARG_SPEC_TYPE posée : Loki se tait", cfg: auto, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPEC_TYPE"] = "draft-mtp" }},
|
||||
{name: "auto, --tensor-split : rien", cfg: auto, extra: []string{"--tensor-split", "0.965,0.035"}, notes: 1},
|
||||
{name: "auto, -ts=… : rien", cfg: auto, extra: []string{"-ts=1,1"}, notes: 1},
|
||||
{name: "auto, NGL chiffré : rien", cfg: with(auto, "NGL", "40"), notes: 1},
|
||||
{name: "auto, -ngl 99 dans EXTRA_ARGS : rien", cfg: auto, extra: []string{"-ngl", "99"}, notes: 1},
|
||||
{name: "auto, -ot : rien", cfg: auto, extra: []string{"-ot", "exps=CPU"}, notes: 1},
|
||||
{name: "auto, --n-cpu-moe : rien", cfg: auto, extra: []string{"--n-cpu-moe", "20"}, notes: 1},
|
||||
{name: "auto, -cmoe : rien", cfg: auto, extra: []string{"-cmoe"}, notes: 1},
|
||||
{name: "auto, -sm row : rien", cfg: auto, extra: []string{"-sm", "row"}, notes: 1},
|
||||
{name: "auto, -fit off : rien", cfg: auto, extra: []string{"-fit", "off"}, notes: 1},
|
||||
{name: "auto, -dev : rien", cfg: auto, extra: []string{"-dev", "CUDA0"}, notes: 1},
|
||||
{name: "auto, LLAMA_ARG_DEVICE : rien", cfg: auto, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_DEVICE"] = "CUDA0" }},
|
||||
{name: "auto, CTX=0 (fit pourrait réduire le contexte) : rien", cfg: with(auto, "CTX", "0"), notes: 1},
|
||||
{name: "auto, projecteur vision : rien", cfg: auto, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.MMProj = "/models/mmproj-F16.gguf" }},
|
||||
{name: "auto, build inconnu : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
|
||||
{name: "auto, moteur trop ancien : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.EngineBuild = 10900 }},
|
||||
{name: "auto, qwen4exp : jamais d'office", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp" }},
|
||||
{name: "auto, essai précédent sans réponse : rien", cfg: auto, notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.SpecAutoBlocked = "le dernier lancement avec cette configuration n'a jamais répondu"
|
||||
}},
|
||||
{name: "auto, moteur sans draft-mtp : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpecOld }},
|
||||
{name: "auto, aide illisible : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.Help = "" }},
|
||||
{name: "mtp imposé malgré -ts : drapeaux, avec l'avertissement", cfg: forced,
|
||||
extra: []string{"-ts", "0.965,0.035"}, want: mtp, notes: 1},
|
||||
{name: "mtp imposé sur qwen4exp, build inconnu", cfg: forced, want: mtp, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp"; s.EngineBuild = 0 }},
|
||||
{name: "mtp imposé, moteur sans draft-mtp : rien", cfg: forced, notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpecOld }},
|
||||
{name: "MODEL_DRAFT tête MTP à part : -md chemin absolu + draft-mtp",
|
||||
cfg: with(auto, "MODEL_DRAFT", "mtp-Qwen3.6-27B-Q8_0.gguf"), wantAuto: true, notes: 1,
|
||||
want: []string{"-md", "/models/mtp-Qwen3.6-27B-Q8_0.gguf", "--spec-type", "draft-mtp", "--spec-draft-sampling", "greedy"},
|
||||
si: func(s *serveSysInfo) {
|
||||
s.GGUF.HasNextNTensor = false
|
||||
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: 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"},
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = "/models/Qwen3-0.6B.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
|
||||
}},
|
||||
{name: "MODEL_DRAFT tête dFlash : pas de draft-simple, réglage à la main",
|
||||
cfg: with(forced, "MODEL_DRAFT", "dflash-Qwen3.6.gguf"), notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = "/models/dflash-Qwen3.6.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "dflash", BlockCount: 5}
|
||||
}},
|
||||
{name: "MODEL_DRAFT tête EAGLE-3 : idem",
|
||||
cfg: with(auto, "MODEL_DRAFT", "eagle3.gguf"), notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = "/models/eagle3.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "eagle3", BlockCount: 1}
|
||||
}},
|
||||
{name: "MODEL_DRAFT introuvable : moteur lancé sans, pas d'erreur",
|
||||
cfg: with(auto, "MODEL_DRAFT", "absent.gguf"), notes: 1,
|
||||
si: func(s *serveSysInfo) { s.DraftErr = "fichier introuvable" }},
|
||||
{name: "MODEL_DRAFT illisible : rien", cfg: with(auto, "MODEL_DRAFT", "mtp-x.gguf"), notes: 1,
|
||||
si: func(s *serveSysInfo) { s.Draft = "/models/mtp-x.gguf" }},
|
||||
{name: "SPEC_N_MAX=2", cfg: with(auto, "SPEC_N_MAX", "2"), wantAuto: true, notes: 1,
|
||||
want: []string{"--spec-type", "draft-mtp", "--spec-draft-n-max", "2", "--spec-draft-sampling", "greedy"}},
|
||||
{name: "SPEC_N_MAX illisible : défaut du moteur", cfg: with(auto, "SPEC_N_MAX", "beaucoup"), want: mtp, wantAuto: true, notes: 2},
|
||||
{name: "--spec-draft-n-max dans EXTRA_ARGS : SPEC_N_MAX se tait, MTP reste",
|
||||
cfg: with(auto, "SPEC_N_MAX", "2"), extra: []string{"--spec-draft-n-max", "4"}, want: mtp, wantAuto: true, notes: 1},
|
||||
{name: "probabilistic jamais avec auto",
|
||||
cfg: with(auto, "SPEC_SAMPLING", "probabilistic"), want: mtp, wantAuto: true, notes: 2},
|
||||
{name: "probabilistic avec mtp imposé", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"), notes: 1,
|
||||
want: []string{"--spec-type", "draft-mtp", "--spec-draft-sampling", "probabilistic"}},
|
||||
{name: "probabilistic refusé avec mirostat", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"),
|
||||
extra: []string{"--mirostat", "2"}, want: mtp, notes: 2},
|
||||
{name: "probabilistic refusé avec adaptive-p", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"),
|
||||
extra: []string{"--samplers", "top_k;adaptive_p"}, want: mtp, notes: 2},
|
||||
{name: "--spec-draft-sampling dans EXTRA_ARGS : on ne le double pas", cfg: auto,
|
||||
extra: []string{"--spec-draft-sampling", "greedy"}, want: []string{"--spec-type", "draft-mtp"}, wantAuto: true, notes: 1},
|
||||
{name: "moteur sans --spec-draft-sampling : pas de drapeau", cfg: auto, wantAuto: true, notes: 1,
|
||||
want: []string{"--spec-type", "draft-mtp"},
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Help = strings.Replace(s.Help, "--spec-draft-sampling {greedy,probabilistic}", "", 1)
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := mtpReady()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes, isAuto := specArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("args = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if isAuto != c.wantAuto {
|
||||
t.Errorf("auto = %v, attendu %v", isAuto, c.wantAuto)
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Errorf("%d notes, attendu %d : %q", len(notes), c.notes, notes)
|
||||
}
|
||||
if specMode(c.cfg) == "auto" && slices.Contains(got, "probabilistic") {
|
||||
t.Errorf("SPEC=auto ne doit jamais choisir le tirage probabiliste : %q", got)
|
||||
}
|
||||
for _, a := range got {
|
||||
if a == "--draft" || a == "--draft-max" || a == "--draft-n" || a == "-ctkd" || a == "-ctvd" {
|
||||
t.Errorf("drapeau interdit %q", a)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// La spéculation passe avant EXTRA_ARGS, qui garde le dernier mot.
|
||||
func TestBuildServeArgsSpec(t *testing.T) {
|
||||
si := mtpReady()
|
||||
args, _, _ := buildServeArgs(map[string]string{"SPEC": "auto"}, []string{"--jinja"}, "/bin/llama-server", si)
|
||||
i := slices.Index(args, "--spec-type")
|
||||
if i < 0 || args[i+1] != "draft-mtp" {
|
||||
t.Fatalf("--spec-type draft-mtp absent : %q", args)
|
||||
}
|
||||
if j := slices.Index(args, "--jinja"); j < i {
|
||||
t.Errorf("EXTRA_ARGS doit fermer la marche : %q", args)
|
||||
}
|
||||
args, _, _ = buildServeArgs(map[string]string{}, nil, "/bin/llama-server", si)
|
||||
if slices.Contains(args, "--spec-type") {
|
||||
t.Errorf("SPEC absent : aucun drapeau attendu, %q", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatefulSampler(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"": {"--mirostat", "0"},
|
||||
"--mirostat 1": {"--mirostat=1"},
|
||||
"--samplers adaptive-p": {"--samplers", "penalties;adaptive-p"},
|
||||
"--sampling-seq avec adaptive-p": {"--sampling-seq", "kpa"},
|
||||
}
|
||||
for want, extra := range cases {
|
||||
if got := statefulSampler(extra); got != want {
|
||||
t.Errorf("statefulSampler(%q) = %q, attendu %q", extra, got, want)
|
||||
}
|
||||
}
|
||||
if got := statefulSampler([]string{"--samplers", "top_k;top_p;temperature"}); got != "" {
|
||||
t.Errorf("chaîne sans état : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAutoVerdict(t *testing.T) {
|
||||
cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351}
|
||||
other := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11400}
|
||||
if why, rec := specAutoVerdict(cur, nil, nil); why != "" || rec {
|
||||
t.Errorf("aucun essai : permis, got %q %v", why, rec)
|
||||
}
|
||||
if why, rec := specAutoVerdict(cur, &cur, map[string]string{}); why == "" || !rec {
|
||||
t.Errorf("jeton resté pour la même combinaison : coupé et inscrit, got %q %v", why, rec)
|
||||
}
|
||||
if why, rec := specAutoVerdict(cur, &other, map[string]string{}); why != "" || rec {
|
||||
t.Errorf("jeton d'un autre build : permis (mise à jour = nouvel essai), got %q %v", why, rec)
|
||||
}
|
||||
if why, rec := specAutoVerdict(cur, nil, map[string]string{cur.key(): "OOM"}); why != "OOM" || rec {
|
||||
t.Errorf("échec inscrit : coupé, got %q %v", why, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// Le cycle complet sur une vraie base : jeton posé, jamais effacé, puis coupé
|
||||
// pour cette combinaison seulement.
|
||||
func TestSpecAutoCheckStore(t *testing.T) {
|
||||
testHome(t)
|
||||
// Ce que fait cmdServe : lire, puis — port libre — ranger et poser le jeton.
|
||||
launch := func(cur specAutoMark, wantAttempt bool) string {
|
||||
why, record := specAutoPeek(cur)
|
||||
specAutoSettle(cur, why, record, wantAttempt && why == "")
|
||||
return why
|
||||
}
|
||||
cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351}
|
||||
if why := launch(cur, true); why != "" {
|
||||
t.Fatalf("base vide : permis, got %q", why)
|
||||
}
|
||||
// Second « loki serve » refusé (port pris) : il a lu, sans rien ranger. Le
|
||||
// jeton du premier, qui charge encore, doit rester intact.
|
||||
if why, _ := specAutoPeek(cur); why == "" {
|
||||
t.Fatal("jeton présent : le lecteur doit le voir")
|
||||
}
|
||||
var a specAutoMark
|
||||
if !getJSON(bkState, specAttemptKey, &a) || a != cur {
|
||||
t.Fatal("specAutoPeek ne doit rien consommer")
|
||||
}
|
||||
if why := launch(cur, true); why == "" {
|
||||
t.Fatal("jeton resté : l'auto doit être coupé")
|
||||
}
|
||||
if getJSON(bkState, specAttemptKey, &a) {
|
||||
t.Error("auto coupé : aucun nouveau jeton")
|
||||
}
|
||||
if why := launch(cur, true); why == "" {
|
||||
t.Error("l'échec doit rester inscrit au lancement suivant")
|
||||
}
|
||||
updated := cur
|
||||
updated.Build = 11400
|
||||
if why := launch(updated, true); why != "" {
|
||||
t.Errorf("moteur mis à jour : nouvel essai permis, got %q", why)
|
||||
}
|
||||
// Le process web efface SON jeton ; pas celui d'un lancement plus récent.
|
||||
specAttemptClear(cur, "")
|
||||
if !getJSON(bkState, specAttemptKey, &a) || a != updated {
|
||||
t.Fatal("le jeton d'un autre lancement doit rester")
|
||||
}
|
||||
specAttemptClear(updated, "")
|
||||
if getJSON(bkState, specAttemptKey, &a) {
|
||||
t.Fatal("le moteur a répondu : jeton effacé")
|
||||
}
|
||||
if why := launch(updated, true); why != "" {
|
||||
t.Errorf("lancement réussi : permis, got %q", why)
|
||||
}
|
||||
// Répondu mais couches en RAM : échec inscrit pour cette combinaison.
|
||||
specAttemptClear(updated, "couches en RAM")
|
||||
if why, _ := specAutoPeek(updated); why != "couches en RAM" {
|
||||
t.Errorf("échec après chargement : coupé, got %q", why)
|
||||
}
|
||||
// Un jeton d'une autre configuration est jeté sans rien conclure.
|
||||
other := specAutoMark{FP: "zzz", Bin: "/e/llama-server", Build: 11351}
|
||||
specAutoSettle(other, "", false, true)
|
||||
if why := launch(cur, false); why == "" {
|
||||
t.Error("échec de cur toujours inscrit")
|
||||
}
|
||||
if getJSON(bkState, specAttemptKey, &a) {
|
||||
t.Error("le jeton étranger doit être consommé")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastOffload(t *testing.T) {
|
||||
log := strings.Join([]string{
|
||||
"srv load_model: loading model '/models/old.gguf'",
|
||||
"load_tensors: offloaded 40/65 layers to GPU",
|
||||
"srv load_model: loading model '/models/new.gguf'",
|
||||
"load_tensors: offloaded 65/65 layers to GPU",
|
||||
"main: server is listening",
|
||||
}, "\n")
|
||||
if n, m := lastOffload(log); n != 65 || m != 65 {
|
||||
t.Errorf("dernier chargement seulement : got %d/%d", n, m)
|
||||
}
|
||||
if n, m := lastOffload("load_tensors: offloaded 10/65 layers to GPU"); n != 0 || m != 0 {
|
||||
t.Errorf("sans ligne de chargement, on ne conclut rien : got %d/%d", n, m)
|
||||
}
|
||||
if n, m := lastOffload("srv load_model: loading model 'x'\nload_tensors: offloaded 50/65 layers to GPU"); n != 50 || m != 65 {
|
||||
t.Errorf("chargement partiel : got %d/%d", n, m)
|
||||
}
|
||||
// Le journal réel de llama-server : « load_model: initializing » APRÈS le
|
||||
// chargement, et un brouillon chargé à part qui a ses propres lignes.
|
||||
srvLog := strings.Join([]string{
|
||||
"srv load_model: loading model '/models/Qwen3.6-27B.gguf'",
|
||||
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
|
||||
"load_tensors: offloaded 52/65 layers to GPU",
|
||||
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
|
||||
"load_tensors: offloaded 2/2 layers to GPU",
|
||||
"srv load_model: initializing, n_slots = 1, n_ctx_slot = 65536, kv_unified = 'false'",
|
||||
"main: server is listening on http://127.0.0.1:8080",
|
||||
}, "\n")
|
||||
if n, m := lastOffload(srvLog); n != 52 || m != 65 {
|
||||
t.Errorf("modèle partiel, brouillon complet : le pire compte, got %d/%d", n, m)
|
||||
}
|
||||
full := strings.Replace(srvLog, "52/65", "65/65", 1)
|
||||
if n, m := lastOffload(full); n != m || m == 0 {
|
||||
t.Errorf("tout sur GPU : got %d/%d", n, m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Threads CPU de llama-server.
|
||||
//
|
||||
// Loki passait toujours « -t <THREADS|0> -tb <THREADS_BATCH|0> ». Or pour
|
||||
// llama.cpp, 0 (ou moins) veut dire std::thread::hardware_concurrency() : TOUS
|
||||
// les threads logiques, frères SMT compris. Avec l'attente active par défaut
|
||||
// (--poll 50), deux threads qui se disputent le même cœur physique se gênent
|
||||
// plus qu'ils ne s'aident — c'est le décodage des experts MoE sur CPU
|
||||
// (--n-cpu-moe) qui paie. Le « 0 = auto » de l'interface mentait donc : ce
|
||||
// n'était pas l'auto de llama.cpp.
|
||||
//
|
||||
// Le vrai auto, c'est l'ABSENCE du drapeau : n_threads reste à -1 et le moteur
|
||||
// prend common_cpu_get_num_math() — les cœurs physiques (sans les cœurs E sur
|
||||
// Intel hybride, sous Linux x86). Un -tb absent recopie -t, et le brouillon
|
||||
// spéculatif hérite des deux. Le nombre de threads ne change que la répartition
|
||||
// du travail, pas le calcul du modèle.
|
||||
//
|
||||
// Effet de bord assumé : sans -t de Loki, une variable LLAMA_ARG_THREADS posée
|
||||
// dans l'environnement du moteur s'applique enfin (le -t 0 l'écrasait).
|
||||
//
|
||||
// Gain non mesuré sur la machine de référence : avant d'en revendiquer un,
|
||||
// comparer tg du preset MoE à physiques, physiques-1 et logiques (MTP actif et
|
||||
// coupé, la vérification spéculative passant par n_threads_batch). Si les
|
||||
// logiques gagnent, poser THREADS dans CE preset plutôt que changer le défaut.
|
||||
// Pour voir le nombre retenu : -lv 4 dans EXTRA_ARGS (system_info est en TRACE).
|
||||
|
||||
// cpuBudget est ce que la sonde de conteneur (cpusetThreads) a conclu. N = 0 :
|
||||
// rien à dire, llama.cpp choisit seul.
|
||||
type cpuBudget struct {
|
||||
N int // threads à imposer via -t
|
||||
Engine int // ce que llama.cpp aurait pris seul, pour la note
|
||||
Why string // raison, en clair, pour stderr
|
||||
}
|
||||
|
||||
// threadCount lit THREADS / THREADS_BATCH. Vide ou 0 : auto, sans un mot. Une
|
||||
// valeur illisible (« auto ») ou négative faisait jusqu'ici tomber le moteur en
|
||||
// boucle sur son analyse d'arguments ; elle est désormais ignorée — mais on le
|
||||
// DIT, plutôt que de la jeter en douce.
|
||||
func threadCount(key, v string) (int, string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, ""
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return 0, fmt.Sprintf("%s=%s ignoré (pas un nombre de threads ≥ 0) → automatique", key, v)
|
||||
}
|
||||
return n, ""
|
||||
}
|
||||
|
||||
// threadArgs compose -t / -tb. Fonction pure, comme nglArgs : la sonde du
|
||||
// conteneur arrive déjà tranchée dans auto.
|
||||
//
|
||||
// THREADS=N>0 → -t N
|
||||
// THREADS vide/0, sonde N>0 → -t N (conteneur à l'étroit, voir cpusetThreads)
|
||||
// THREADS vide/0 → rien (cœurs physiques, choisis par llama.cpp)
|
||||
// THREADS_BATCH=N>0 → -tb N
|
||||
// THREADS_BATCH vide/0 → rien (le moteur recopie -t)
|
||||
// … sauf -Cb/--poll-batch… → -tb <valeur de -t, sinon 0> (voir plus bas)
|
||||
//
|
||||
// Un -t / -tb déjà écrit dans EXTRA_ARGS gagne : on ne double pas le drapeau.
|
||||
// Un masque d'affinité posé à la main (-C, -Cr, --cpu-strict) désarme la sonde :
|
||||
// l'utilisateur a pris la main sur le placement, on ne devine rien par-dessus.
|
||||
//
|
||||
// Piège du -tb absent : llama.cpp ne recopie pas seulement le NOMBRE de -t, il
|
||||
// remplace TOUT le réglage CPU du batch par celui de -t (postprocess_cpu_params,
|
||||
// « cpuparams = *role_model »). Un -Cb, --poll-batch ou --prio-batch écrit dans
|
||||
// EXTRA_ARGS serait alors effacé sans un mot — le -tb 0 d'avant le protégeait.
|
||||
// Dans ce cas seulement, on pose donc -tb : la valeur de -t si on la connaît
|
||||
// (« comme THREADS »), sinon 0, l'ancien comportement.
|
||||
func threadArgs(threads, threadsBatch string, extra []string, auto cpuBudget) (args, notes []string) {
|
||||
t, note := threadCount("THREADS", threads)
|
||||
if note != "" {
|
||||
notes = append(notes, note)
|
||||
}
|
||||
tVal := "" // valeur de -t effective, si on la connaît
|
||||
switch {
|
||||
case hasAnyFlag(extra, "-t", "--threads"):
|
||||
tVal = flagValue(extra, "-t", "--threads")
|
||||
case t > 0:
|
||||
tVal = strconv.Itoa(t)
|
||||
args = append(args, "-t", tVal)
|
||||
case auto.N > 0 && !hasAnyFlag(extra, "-C", "--cpu-mask", "-Cr", "--cpu-range", "--cpu-strict"):
|
||||
tVal = strconv.Itoa(auto.N)
|
||||
args = append(args, "-t", tVal)
|
||||
notes = append(notes, fmt.Sprintf("threads auto → -t %d : %s (llama.cpp seul en lancerait %d)",
|
||||
auto.N, auto.Why, auto.Engine))
|
||||
}
|
||||
tb, note := threadCount("THREADS_BATCH", threadsBatch)
|
||||
if note != "" {
|
||||
notes = append(notes, note)
|
||||
}
|
||||
switch {
|
||||
case hasAnyFlag(extra, "-tb", "--threads-batch"):
|
||||
case tb > 0:
|
||||
args = append(args, "-tb", strconv.Itoa(tb))
|
||||
case hasAnyFlag(extra, batchCPUFlags...):
|
||||
if tVal == "" {
|
||||
tVal = "0"
|
||||
notes = append(notes, "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que "+
|
||||
"llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre")
|
||||
}
|
||||
args = append(args, "-tb", tVal)
|
||||
}
|
||||
return args, notes
|
||||
}
|
||||
|
||||
// batchCPUFlags : les réglages CPU propres au batch, qu'un -tb absent effacerait.
|
||||
var batchCPUFlags = []string{"-Cb", "--cpu-mask-batch", "-Crb", "--cpu-range-batch",
|
||||
"--cpu-strict-batch", "--prio-batch", "--poll-batch"}
|
||||
|
||||
// flagValue renvoie la valeur de la DERNIÈRE occurrence de l'un des drapeaux
|
||||
// (« -t 6 » ou « -t=6 ») — celle que retient le moteur. Vide s'il n'y en a pas.
|
||||
func flagValue(args []string, flags ...string) string {
|
||||
v := ""
|
||||
for i, a := range args {
|
||||
name, val, hasEq := strings.Cut(a, "=")
|
||||
for _, f := range flags {
|
||||
if name != f {
|
||||
continue
|
||||
}
|
||||
if hasEq {
|
||||
v = val
|
||||
} else if i+1 < len(args) {
|
||||
v = args[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// cpusetThreads : sonde de conteneur, Linux seulement (fsys = la racine « / »).
|
||||
//
|
||||
// L'auto de llama.cpp se trompe quand le conteneur n'a droit qu'à une partie
|
||||
// de la machine :
|
||||
// - cpuset restreint (docker --cpuset-cpus) : common_cpu_get_num_math essaie
|
||||
// d'épingler un thread sur chaque CPU de l'hôte, échoue hors du cpuset et
|
||||
// se rabat sur TOUS les cœurs physiques de l'hôte (sysfs n'est pas cloisonné),
|
||||
// cœurs E compris ;
|
||||
// - quota CFS (docker --cpus) : invisible dans l'affinité, il étrangle des
|
||||
// threads qui attendent en boucle active — le pire cas.
|
||||
//
|
||||
// On compte alors comme llama.cpp (chaînes thread_siblings distinctes, cœurs E
|
||||
// de /sys/devices/cpu_atom/cpus écartés), mais sur les seuls CPU permis, plafonné
|
||||
// par le quota. On ne renvoie un nombre que s'il est PLUS PETIT que ce que le
|
||||
// moteur aurait pris seul ; à la moindre lecture ratée, rien (le moteur décide).
|
||||
func cpusetThreads(fsys fs.FS) cpuBudget {
|
||||
online, err := readCPUList(fsys, "sys/devices/system/cpu/online")
|
||||
if err != nil || len(online) == 0 {
|
||||
return cpuBudget{}
|
||||
}
|
||||
allowed, err := procAllowedCPUs(fsys)
|
||||
if err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
allowed = intersectCPUs(allowed, online)
|
||||
if len(allowed) == 0 {
|
||||
return cpuBudget{}
|
||||
}
|
||||
restricted := len(allowed) < len(online)
|
||||
quota := cgroupCPUQuota(fsys)
|
||||
if !restricted && quota == 0 {
|
||||
return cpuBudget{}
|
||||
}
|
||||
// Absent = CPU non hybride : aucun cœur E à écarter.
|
||||
atom, _ := readCPUList(fsys, "sys/devices/cpu_atom/cpus")
|
||||
|
||||
hostAll, err := physicalCores(fsys, online, nil)
|
||||
if err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
hostMath, err := physicalCores(fsys, online, atom)
|
||||
if err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
if hostMath == 0 {
|
||||
hostMath = hostAll
|
||||
}
|
||||
|
||||
var n, engine int
|
||||
var why string
|
||||
if restricted {
|
||||
engine = hostAll // épinglage refusé hors cpuset → repli sur tous les cœurs de l'hôte
|
||||
if n, err = physicalCores(fsys, allowed, atom); err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
if n == 0 { // conteneur cantonné aux cœurs E : on les prend, faute de mieux
|
||||
if n, err = physicalCores(fsys, allowed, nil); err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
}
|
||||
why = fmt.Sprintf("cpuset du conteneur, %d cœurs physiques permis sur %d", n, hostAll)
|
||||
if len(atom) > 0 {
|
||||
why += " (cœurs E écartés)"
|
||||
}
|
||||
} else {
|
||||
engine, n = hostMath, hostMath
|
||||
}
|
||||
if quota > 0 && quota < n {
|
||||
n = quota
|
||||
why = fmt.Sprintf("quota cgroup de %d CPU", quota)
|
||||
}
|
||||
if n <= 0 || n >= engine {
|
||||
return cpuBudget{}
|
||||
}
|
||||
return cpuBudget{N: n, Engine: engine, Why: why}
|
||||
}
|
||||
|
||||
// physicalCores compte les cœurs physiques parmi cpus comme le fait
|
||||
// common_cpu_get_num_physical_cores : une chaîne thread_siblings distincte par
|
||||
// cœur — juste aussi sur les machines multi-puces, où (package, core_id) peut
|
||||
// se répéter. Les CPU de exclude (cœurs E) ne comptent pas.
|
||||
func physicalCores(fsys fs.FS, cpus, exclude []int) (int, error) {
|
||||
skip := map[int]bool{}
|
||||
for _, c := range exclude {
|
||||
skip[c] = true
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range cpus {
|
||||
if skip[c] {
|
||||
continue
|
||||
}
|
||||
b, err := fs.ReadFile(fsys, fmt.Sprintf("sys/devices/system/cpu/cpu%d/topology/thread_siblings", c))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seen[strings.TrimSpace(string(b))] = true
|
||||
}
|
||||
return len(seen), nil
|
||||
}
|
||||
|
||||
// procAllowedCPUs lit Cpus_allowed_list dans /proc/self/status : l'affinité
|
||||
// réelle du processus, cpuset du conteneur compris.
|
||||
func procAllowedCPUs(fsys fs.FS) ([]int, error) {
|
||||
b, err := fs.ReadFile(fsys, "proc/self/status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "Cpus_allowed_list:"); ok {
|
||||
return parseCPUList(v)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("pas de Cpus_allowed_list")
|
||||
}
|
||||
|
||||
// cgroupCPUQuota renvoie le quota CPU du cgroup arrondi au CPU supérieur
|
||||
// (au moins 1), 0 s'il n'y en a pas ou s'il est illisible. cgroup v2
|
||||
// (cpu.max : « max 100000 » ou « 250000 100000 »), sinon v1 (cfs_quota_us,
|
||||
// -1 = illimité). Seul un conteneur voit son propre cgroup sous /sys/fs/cgroup.
|
||||
func cgroupCPUQuota(fsys fs.FS) int {
|
||||
if b, err := fs.ReadFile(fsys, "sys/fs/cgroup/cpu.max"); err == nil {
|
||||
f := strings.Fields(string(b))
|
||||
if len(f) == 0 || f[0] == "max" {
|
||||
return 0
|
||||
}
|
||||
period := "100000"
|
||||
if len(f) > 1 {
|
||||
period = f[1]
|
||||
}
|
||||
return ceilQuota(f[0], period)
|
||||
}
|
||||
for _, dir := range []string{"sys/fs/cgroup/cpu", "sys/fs/cgroup/cpu,cpuacct"} {
|
||||
q, err := fs.ReadFile(fsys, dir+"/cpu.cfs_quota_us")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, err := fs.ReadFile(fsys, dir+"/cpu.cfs_period_us")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return ceilQuota(strings.TrimSpace(string(q)), strings.TrimSpace(string(p)))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ceilQuota(quota, period string) int {
|
||||
q, err1 := strconv.ParseInt(quota, 10, 64)
|
||||
p, err2 := strconv.ParseInt(period, 10, 64)
|
||||
if err1 != nil || err2 != nil || q <= 0 || p <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(max(1, (q+p-1)/p))
|
||||
}
|
||||
|
||||
// readCPUList lit un fichier au format liste du noyau (« 0-3,8-11 »).
|
||||
func readCPUList(fsys fs.FS, name string) ([]int, error) {
|
||||
b, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseCPUList(string(b))
|
||||
}
|
||||
|
||||
// parseCPUList décode « 0-3,8-11 » en liste triée, sans doublon. Une liste
|
||||
// vide est valide (aucun CPU) ; une syntaxe inconnue est une erreur.
|
||||
func parseCPUList(s string) ([]int, error) {
|
||||
set := map[int]bool{}
|
||||
for _, part := range strings.Split(strings.TrimSpace(s), ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
lo, hi, isRange := strings.Cut(part, "-")
|
||||
a, err := strconv.Atoi(lo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := a
|
||||
if isRange {
|
||||
if b, err = strconv.Atoi(hi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if a < 0 || b < a || b-a > 1<<16 {
|
||||
return nil, fmt.Errorf("liste de CPU invalide : %q", part)
|
||||
}
|
||||
for c := a; c <= b; c++ {
|
||||
set[c] = true
|
||||
}
|
||||
}
|
||||
out := make([]int, 0, len(set))
|
||||
for c := range set {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func intersectCPUs(a, b []int) []int {
|
||||
in := map[int]bool{}
|
||||
for _, c := range b {
|
||||
in[c] = true
|
||||
}
|
||||
var out []int
|
||||
for _, c := range a {
|
||||
if in[c] {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// cpuFS fabrique un /proc + /sys minimal : online, l'affinité du processus et,
|
||||
// pour chaque CPU en ligne, sa chaîne thread_siblings (siblings[cpu]).
|
||||
func cpuFS(online, allowed string, siblings map[int]string, extra map[string]string) fstest.MapFS {
|
||||
m := fstest.MapFS{
|
||||
"sys/devices/system/cpu/online": {Data: []byte(online + "\n")},
|
||||
"proc/self/status": {Data: []byte("Name:\tllama-server\nCpus_allowed:\tffff\nCpus_allowed_list:\t" + allowed + "\nMems_allowed:\t1\n")},
|
||||
}
|
||||
for c, s := range siblings {
|
||||
m[fmt.Sprintf("sys/devices/system/cpu/cpu%d/topology/thread_siblings", c)] = &fstest.MapFile{Data: []byte(s + "\n")}
|
||||
}
|
||||
for k, v := range extra {
|
||||
m[k] = &fstest.MapFile{Data: []byte(v)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// smtPairs : n cœurs à deux threads, frères adjacents (0-1, 2-3…) — la
|
||||
// numérotation que suppose cpu_count_math_cpus.
|
||||
func smtPairs(cores, from int) map[int]string {
|
||||
m := map[int]string{}
|
||||
for i := 0; i < cores; i++ {
|
||||
s := fmt.Sprintf("core%d", from/2+i)
|
||||
m[from+2*i], m[from+2*i+1] = s, s
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// hybrid : 8 cœurs P à deux threads (CPU 0-15) + 8 cœurs E (CPU 16-23).
|
||||
func hybrid() map[int]string {
|
||||
m := smtPairs(8, 0)
|
||||
for c := 16; c < 24; c++ {
|
||||
m[c] = fmt.Sprintf("ecore%d", c)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestCPUSetThreads(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fs fstest.MapFS
|
||||
n int
|
||||
}{
|
||||
{"machine entière, sans quota : rien", cpuFS("0-15", "0-15", smtPairs(8, 0), nil), 0},
|
||||
{"cpuset de 8 threads SMT : 4 cœurs", cpuFS("0-15", "0-7", smtPairs(8, 0), nil), 4},
|
||||
{
|
||||
// L'exemple qui tournait mal : tout sauf cpu0 sur un Intel hybride.
|
||||
// Les cœurs E sont écartés, cpu1 partage son cœur avec cpu0 → 8, pas 15.
|
||||
"hybride, tout sauf cpu0 : 8 cœurs P",
|
||||
cpuFS("0-23", "1-23", hybrid(), map[string]string{"sys/devices/cpu_atom/cpus": "16-23\n"}), 8,
|
||||
},
|
||||
{
|
||||
"hybride, cantonné aux cœurs E : on les prend",
|
||||
cpuFS("0-23", "16-19", hybrid(), map[string]string{"sys/devices/cpu_atom/cpus": "16-23\n"}), 4,
|
||||
},
|
||||
{
|
||||
"quota v2 de 2,5 CPU : arrondi à 3",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "250000 100000\n"}), 3,
|
||||
},
|
||||
{
|
||||
"quota v2 illimité : rien",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "max 100000\n"}), 0,
|
||||
},
|
||||
{
|
||||
"quota plus large que les cœurs : rien",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "1200000 100000\n"}), 0,
|
||||
},
|
||||
{
|
||||
"quota v1 de 2 CPU",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{
|
||||
"sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us": "200000\n",
|
||||
"sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us": "100000\n"}), 2,
|
||||
},
|
||||
{
|
||||
"quota v1 illimité (-1) : rien",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{
|
||||
"sys/fs/cgroup/cpu/cpu.cfs_quota_us": "-1\n",
|
||||
"sys/fs/cgroup/cpu/cpu.cfs_period_us": "100000\n"}), 0,
|
||||
},
|
||||
{
|
||||
"cpuset ET quota : le plus petit",
|
||||
cpuFS("0-15", "0-7", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "200000 100000"}), 2,
|
||||
},
|
||||
{
|
||||
"topologie illisible : on ne devine pas",
|
||||
cpuFS("0-15", "0-7", map[int]string{0: "core0"}, nil), 0,
|
||||
},
|
||||
{"pas de /proc : rien", fstest.MapFS{"sys/devices/system/cpu/online": {Data: []byte("0-3")}}, 0},
|
||||
{"liste illisible : rien", cpuFS("0-15", "0-x", smtPairs(8, 0), nil), 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := cpusetThreads(c.fs)
|
||||
if got.N != c.n {
|
||||
t.Fatalf("N = %d (%+v), want %d", got.N, got, c.n)
|
||||
}
|
||||
if got.N > 0 && (got.Why == "" || got.Engine <= got.N) {
|
||||
t.Fatalf("budget incomplet : %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCPUList(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
want []int
|
||||
ok bool
|
||||
}{
|
||||
{"0-3,8-9\n", []int{0, 1, 2, 3, 8, 9}, true},
|
||||
{" 5 ", []int{5}, true},
|
||||
{"2,0-2", []int{0, 1, 2}, true},
|
||||
{"", []int{}, true},
|
||||
{"3-1", nil, false},
|
||||
{"a", nil, false},
|
||||
} {
|
||||
got, err := parseCPUList(c.in)
|
||||
if (err == nil) != c.ok || (c.ok && !reflect.DeepEqual(got, c.want)) {
|
||||
t.Errorf("parseCPUList(%q) = %v, %v ; want %v", c.in, got, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadCount(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
n int
|
||||
note bool
|
||||
}{
|
||||
{"", 0, false}, {"0", 0, false}, {" 8 ", 8, false}, {"auto", 0, true}, {"-1", 0, true},
|
||||
} {
|
||||
n, note := threadCount("THREADS", c.in)
|
||||
if n != c.n || (note != "") != c.note {
|
||||
t.Errorf("threadCount(%q) = %d, %q", c.in, n, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagValue(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{[]string{"-t", "6"}, "6"},
|
||||
{[]string{"--threads=7"}, "7"},
|
||||
{[]string{"-t", "6", "--threads", "8"}, "8"}, // la dernière gagne, comme dans le moteur
|
||||
{[]string{"-tb", "9"}, ""}, // -tb n'est pas -t
|
||||
{[]string{"-t"}, ""}, // drapeau orphelin
|
||||
} {
|
||||
if got := flagValue(c.args, "-t", "--threads"); got != c.want {
|
||||
t.Errorf("flagValue(%q) = %q, want %q", c.args, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package loki
|
||||
|
||||
// backup_bundle.go — construit et restaure un paquet de sauvegarde
|
||||
// {mémoire, presets, réglages}, CHIFFRÉ côté agent avant de quitter la machine.
|
||||
//
|
||||
// Le relais ne voit qu'un blob opaque (voir jean-relay/backup.go). Comme le tar
|
||||
// entier est chiffré, même les noms de fichiers et de presets sont cachés.
|
||||
//
|
||||
// Format du blob :
|
||||
// magic "LOKIBK1" || uint32(len entête) || entête JSON || corps
|
||||
// entête : { v, salt, kdf, box_pw, box_rec } — la DEK de sauvegarde wrappée
|
||||
// par la KEK(mot de passe) ET par la KEK(clé de récupération).
|
||||
// corps : gcmSeal(backupDEK, tar.gz du bundle).
|
||||
//
|
||||
// Restauration : mot de passe → KEK → déballe la DEK → déchiffre le corps. Ne
|
||||
// dépend d'AUCUN état de l'ancienne machine : un serveur vierge suffit.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var backupMagic = []byte("LKBK1") // v2 : entête = keyvault, corps chiffré par la DEK
|
||||
|
||||
const backupBodyAAD = "loki-backup-body"
|
||||
|
||||
// buildBundleTar empaquète mémoire + presets + réglages en un tar.gz.
|
||||
// Les fichiers mémoire sont pris TELS QUELS (chiffrés si le chiffrement est
|
||||
// actif) : le bundle reste donc chiffré même avant l'enveloppe de sauvegarde.
|
||||
func buildBundleTar() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
addDir := func(prefix, dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil // dossier absent = rien à ajouter
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &tar.Header{Name: prefix + "/" + e.Name(), Mode: 0o600, Size: int64(len(b))}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mémoire de TOUS les projets : un sous-dossier par projet (memory/<slug>/...).
|
||||
if roots, err := os.ReadDir(projectsRoot()); err == nil {
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := addDir("memory/"+d.Name(), filepath.Join(projectsRoot(), d.Name())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := addDir("presets", presetsDir()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Réglages (bucket config) sérialisés en JSON.
|
||||
cfg, _ := json.Marshal(ReadConfig())
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "config.json", Mode: 0o600, Size: int64(len(cfg))}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tw.Write(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildBackupBlob assemble le paquet chiffré : magic || uint32(len keyvault) ||
|
||||
// keyvault JSON || gcmSeal(DEK, tar). AUCUN mot de passe de sauvegarde : le corps
|
||||
// est chiffré avec la DEK de la mémoire (déjà en RAM), et le keyvault (la DEK
|
||||
// wrappée par la clé d'API + la clé de récupération) voyage en entête. La
|
||||
// restauration n'a donc besoin que de la clé d'API. Le relais ne voit qu'un blob
|
||||
// opaque : le keyvault en entête ne contient que la DEK CHIFFRÉE.
|
||||
func buildBackupBlob(v *keyVault, dek, tarData []byte) ([]byte, error) {
|
||||
kv, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := gcmSeal(dek, tarData, []byte(backupBodyAAD))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
out.Write(backupMagic)
|
||||
var lenbuf [4]byte
|
||||
binary.BigEndian.PutUint32(lenbuf[:], uint32(len(kv)))
|
||||
out.Write(lenbuf[:])
|
||||
out.Write(kv)
|
||||
out.Write(body)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// openBackupBlob lit l'entête keyvault, l'ouvre avec secret (clé d'API OU clé de
|
||||
// récupération) pour retrouver la DEK, puis déchiffre le corps.
|
||||
func openBackupBlob(blob []byte, secret string) ([]byte, error) {
|
||||
if !bytes.HasPrefix(blob, backupMagic) {
|
||||
return nil, fmt.Errorf("format de sauvegarde non reconnu")
|
||||
}
|
||||
rest := blob[len(backupMagic):]
|
||||
if len(rest) < 4 {
|
||||
return nil, fmt.Errorf("blob tronqué")
|
||||
}
|
||||
n := binary.BigEndian.Uint32(rest[:4])
|
||||
rest = rest[4:]
|
||||
if int(n) > len(rest) {
|
||||
return nil, fmt.Errorf("entête tronqué")
|
||||
}
|
||||
var v keyVault
|
||||
if err := json.Unmarshal(rest[:n], &v); err != nil {
|
||||
return nil, fmt.Errorf("entête illisible : %w", err)
|
||||
}
|
||||
body := rest[n:]
|
||||
dek, _, err := v.unlockWith(secret)
|
||||
if err != nil {
|
||||
dek, _, err = v.unlockWith(normalizeRecovery(secret))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clé incorrecte")
|
||||
}
|
||||
return gcmOpen(dek, body, []byte(backupBodyAAD))
|
||||
}
|
||||
|
||||
// restoreBundleTar réécrit mémoire + presets + réglages depuis un tar.gz. Prend
|
||||
// D'ABORD un snapshot de sécurité de la mémoire, puis écrit de façon vérifiée.
|
||||
func restoreBundleTar(tarData []byte) error {
|
||||
if _, err := snapshotMemory("avant-restauration"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(tarData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var cfgJSON []byte
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Les chemins tar sont TOUJOURS à séparateur « / » (jamais l'OS) : on
|
||||
// raisonne en slash, sinon le préfixe "memory/" casse sous Windows.
|
||||
name := path.Clean("/" + strings.ReplaceAll(hdr.Name, "\\", "/"))[1:]
|
||||
if strings.Contains(name, "..") {
|
||||
continue // sécurité : jamais de traversée
|
||||
}
|
||||
base := path.Base(name)
|
||||
switch {
|
||||
case name == "config.json":
|
||||
cfgJSON = b
|
||||
case strings.HasPrefix(name, "memory/"):
|
||||
// Chemin relatif SOUS memory/ (memory/<slug>/page.md). Les sauvegardes
|
||||
// d'avant les projets stockaient les pages à plat (memory/page.md) : on
|
||||
// les rattache au projet par défaut.
|
||||
rel := strings.TrimPrefix(name, "memory/")
|
||||
if !strings.Contains(rel, "/") {
|
||||
rel = defaultProjectSlug + "/" + rel
|
||||
}
|
||||
dst := filepath.Join(projectsRoot(), filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(dst, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.HasPrefix(name, "presets/"):
|
||||
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(presetsDir(), base), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cfgJSON) > 0 {
|
||||
var cfg map[string]string
|
||||
if json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||
_ = WriteConfig(cfg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un paquet exporté doit se rouvrir avec la clé de pilotage comme avec la clé de
|
||||
// récupération, et rendre la mémoire à l'identique. C'est le scénario « je
|
||||
// remonte mon conteneur ailleurs » : rien d'autre que le fichier et la clé.
|
||||
func TestBackupBundleRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("notes.md", "# Notes\nsecret du client\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovery, err := EnableMemEncryption("clé-de-pilotage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
t.Fatalf("coffre introuvable après activation: %v", err)
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarData, err := buildBundleTar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, err := buildBackupBlob(v, dek, tarData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Le paquet ne doit rien laisser voir en clair.
|
||||
if strings.Contains(string(blob), "secret du client") {
|
||||
t.Fatal("le contenu d'une page apparaît en clair dans le paquet")
|
||||
}
|
||||
|
||||
// La clé de pilotage l'ouvre…
|
||||
if _, err := openBackupBlob(blob, "clé-de-pilotage"); err != nil {
|
||||
t.Fatalf("ouverture avec la clé de pilotage: %v", err)
|
||||
}
|
||||
// …la clé de récupération aussi…
|
||||
back, err := openBackupBlob(blob, recovery)
|
||||
if err != nil {
|
||||
t.Fatalf("ouverture avec la clé de récupération: %v", err)
|
||||
}
|
||||
// …et une mauvaise clé, non.
|
||||
if _, err := openBackupBlob(blob, "pas la bonne"); err == nil {
|
||||
t.Fatal("une clé fausse aurait dû être refusée")
|
||||
}
|
||||
|
||||
// Page effacée puis restaurée depuis le paquet : contenu intact.
|
||||
p, _ := safeMemPath("notes.md")
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restoreBundleTar(back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := memPageText("notes.md")
|
||||
if !ok || !strings.Contains(got, "secret du client") {
|
||||
t.Fatalf("page non restaurée: %q (lisible=%v)", got, ok)
|
||||
}
|
||||
// Le snapshot de sécurité pris avant restauration doit exister.
|
||||
if entries, err := os.ReadDir(filepath.Join(LokiHome(), "backups", "memory")); err != nil || len(entries) == 0 {
|
||||
t.Fatal("aucun snapshot pris avant la restauration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package loki
|
||||
|
||||
// browser_grid.go — surimpose une GRILLE DE COORDONNÉES sur le screenshot envoyé
|
||||
// au MODÈLE (pas sur celui sauvegardé pour l'utilisateur). Un petit modèle vise
|
||||
// mal au pixel près ; avec des lignes graduées tous les 100 px et des étiquettes
|
||||
// chiffrées, il lit x/y précisément et browser_click_xy tombe juste du premier
|
||||
// coup (bandeaux cookies en iframe, canvas, jeux…). Sans dépendance externe : la
|
||||
// police de chiffres est un bitmap 3×5 codé ici.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const gridStep = 100 // espacement des lignes, en pixels
|
||||
|
||||
// digits5x3 : bitmap 5 lignes × 3 colonnes pour chaque chiffre (# = pixel plein).
|
||||
var digits5x3 = map[rune][5]string{
|
||||
'0': {"###", "# #", "# #", "# #", "###"},
|
||||
'1': {" # ", "## ", " # ", " # ", "###"},
|
||||
'2': {"###", " #", "###", "# ", "###"},
|
||||
'3': {"###", " #", "###", " #", "###"},
|
||||
'4': {"# #", "# #", "###", " #", " #"},
|
||||
'5': {"###", "# ", "###", " #", "###"},
|
||||
'6': {"###", "# ", "###", "# #", "###"},
|
||||
'7': {"###", " #", " #", " #", " #"},
|
||||
'8': {"###", "# #", "###", "# #", "###"},
|
||||
'9': {"###", "# #", "###", " #", "###"},
|
||||
}
|
||||
|
||||
// overlayGrid décode un PNG, y dessine la grille + les graduations, et renvoie le
|
||||
// PNG résultant. En cas d'erreur (image illisible), renvoie l'entrée inchangée.
|
||||
func overlayGrid(raw []byte) []byte {
|
||||
src, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
b := src.Bounds()
|
||||
img := image.NewRGBA(b)
|
||||
draw.Draw(img, b, src, b.Min, draw.Src)
|
||||
|
||||
line := color.RGBA{255, 0, 200, 130} // magenta translucide pour les lignes
|
||||
labelBG := color.RGBA{0, 0, 0, 200} // fond sombre derrière les chiffres
|
||||
labelFG := color.RGBA{255, 255, 0, 255} // chiffres jaunes (contraste sur toute page)
|
||||
w, h := b.Dx(), b.Dy()
|
||||
|
||||
// Lignes verticales + étiquette x en haut.
|
||||
for x := gridStep; x < w; x += gridStep {
|
||||
for y := 0; y < h; y++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+x+2, b.Min.Y+2, strconv.Itoa(x), labelFG, labelBG)
|
||||
}
|
||||
// Lignes horizontales + étiquette y à gauche.
|
||||
for y := gridStep; y < h; y += gridStep {
|
||||
for x := 0; x < w; x++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+2, b.Min.Y+y+2, strconv.Itoa(y), labelFG, labelBG)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return raw
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// blend applique une couleur semi-transparente (alpha) sur un pixel.
|
||||
func blend(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if !(image.Pt(x, y).In(img.Bounds())) {
|
||||
return
|
||||
}
|
||||
a := float64(c.A) / 255
|
||||
o := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(float64(c.R)*a + float64(o.R)*(1-a)),
|
||||
G: uint8(float64(c.G)*a + float64(o.G)*(1-a)),
|
||||
B: uint8(float64(c.B)*a + float64(o.B)*(1-a)),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
|
||||
// drawLabel écrit un nombre (police 3×5, échelle ×2) avec un fond sombre pour
|
||||
// rester lisible sur n'importe quel fond.
|
||||
func drawLabel(img *image.RGBA, x, y int, s string, fg, bg color.RGBA) {
|
||||
const sc = 2
|
||||
glyphW, glyphH, gap := 3*sc, 5*sc, sc
|
||||
boxW := len(s)*(glyphW+gap) + gap
|
||||
boxH := glyphH + 2*gap
|
||||
// Fond.
|
||||
for dy := -gap; dy < boxH-gap; dy++ {
|
||||
for dx := -gap; dx < boxW-gap; dx++ {
|
||||
setOpaque(img, x+dx, y+dy, bg)
|
||||
}
|
||||
}
|
||||
// Chiffres.
|
||||
cx := x
|
||||
for _, r := range s {
|
||||
g, ok := digits5x3[r]
|
||||
if !ok {
|
||||
cx += glyphW + gap
|
||||
continue
|
||||
}
|
||||
for row := 0; row < 5; row++ {
|
||||
for col := 0; col < 3; col++ {
|
||||
if g[row][col] != '#' {
|
||||
continue
|
||||
}
|
||||
for iy := 0; iy < sc; iy++ {
|
||||
for ix := 0; ix < sc; ix++ {
|
||||
setOpaque(img, cx+col*sc+ix, y+row*sc+iy, fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += glyphW + gap
|
||||
}
|
||||
}
|
||||
|
||||
func setOpaque(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if image.Pt(x, y).In(img.Bounds()) {
|
||||
img.SetRGBA(x, y, color.RGBA{c.R, c.G, c.B, 255})
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,8 @@ func cmdChat(args []string) error {
|
||||
msgs = compacted
|
||||
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
|
||||
}
|
||||
extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool {
|
||||
sent, tools := prepareTurn(msgs, caps)
|
||||
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()))
|
||||
|
||||
+336
-21
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -214,6 +215,69 @@ func compactWouldTrigger(msgs []Message, knownTokens int) bool {
|
||||
return used >= int(float64(ctxWindow())*compactTriggerFrac)
|
||||
}
|
||||
|
||||
// compactNeeded = compactWouldTrigger, plus une garde de MARGE DE GÉNÉRATION.
|
||||
// Tant que le raisonnement de la dernière étape était compté dans le contexte
|
||||
// (à tort : il n'est jamais renvoyé), il offrait par accident une marge à peu
|
||||
// près proportionnelle à la longueur des étapes. Ce compte est désormais juste,
|
||||
// et sans max_tokens ni budget de raisonnement, une étape aussi longue que la
|
||||
// plus longue récente doit encore tenir : sinon le moteur coupe en plein
|
||||
// raisonnement (« length »). peakGen = 0 (inconnu, API externe) : seuil seul.
|
||||
func compactNeeded(msgs []Message, used, peakGen int) bool {
|
||||
if compactWouldTrigger(msgs, used) {
|
||||
return true
|
||||
}
|
||||
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Plancher et marge sont choisis pour ne JAMAIS devancer le seuil de 75 % à eux
|
||||
// seuls : seule une génération longue avance la compaction.
|
||||
//
|
||||
// Et jamais avant la MOITIÉ de la fenêtre, si longue que soit la génération :
|
||||
// une étape de 40 k sur 65 k (ou de 20 k sur 32 k) réclamait sinon une place
|
||||
// que l'historique compacté lui-même — prompt système, outils, résumé, queue —
|
||||
// ne laisse pas. La garde repartait alors à CHAQUE étape, résumé de résumé, le
|
||||
// fil perdu pour rien. Au-delà, c'est le rejeu sur « length » qui rattrape.
|
||||
func genHeadroomShort(used, peakGen, window int) bool {
|
||||
if used <= 0 || peakGen <= 0 || window <= 0 {
|
||||
return false
|
||||
}
|
||||
need := max(peakGen*6/5, min(8192, window/8)) + min(1024, window/32)
|
||||
return used+min(need, window/2) > window
|
||||
}
|
||||
|
||||
// logCtx trace un fait du comptage de contexte (fenêtre pleine, relance après
|
||||
// un raisonnement sans action) : de quoi vérifier, journal en main, qu'ils ne
|
||||
// deviennent pas plus fréquents.
|
||||
func logCtx(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "[ctx] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// logCtxEstimate compare le contexte ESTIMÉ avant une requête au compte réel
|
||||
// du moteur (usage.prompt_tokens), et ne parle qu'au-delà de 2 % d'écart.
|
||||
func logCtxEstimate(phase string, est, actual int) {
|
||||
if actual <= 0 {
|
||||
return
|
||||
}
|
||||
if d := est - actual; d*50 > actual || -d*50 > actual {
|
||||
logCtx("%s estimé=%d réel=%d écart=%+d", phase, est, actual, d)
|
||||
}
|
||||
}
|
||||
|
||||
// ctxPending ajoute au dernier contexte mesuré (used, valable pour les
|
||||
// measured premiers messages) l'estimation des messages arrivés depuis : le
|
||||
// nouveau message utilisateur, ceux de la file. Le compte du moteur ne les a
|
||||
// pas vus. measured = 0 (inconnu, discussion rechargée) ou plus grand que
|
||||
// l'historique (réécrit entre-temps) : on s'en tient au compte mesuré.
|
||||
func ctxPending(used, measured int, msgs []Message) int {
|
||||
if used <= 0 || measured <= 0 || measured > len(msgs) {
|
||||
return used
|
||||
}
|
||||
return used + estimateTokens(msgs[measured:])
|
||||
}
|
||||
|
||||
// logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur
|
||||
// (donc dans `journalctl -u loki-ui`). Sans ça, une compaction qui ne se
|
||||
// déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté
|
||||
@@ -274,7 +338,14 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
|
||||
break
|
||||
}
|
||||
}
|
||||
for tailStart > head && msgs[tailStart].Role != "user" && msgs[tailStart].Role != "assistant" {
|
||||
// Jamais sur un rappel de loki (isLokiInjected) : il a le rôle `user` sans
|
||||
// être une demande. En tête de queue, la vraie demande réinjectée juste
|
||||
// avant (pending, voir compactMessages) lui serait collée — deux `user`
|
||||
// d'affilée, que les gabarits à alternance stricte refusent à chaque tour.
|
||||
// On recule jusqu'à l'appel d'outil qui le précède : la queue garde un
|
||||
// groupe de plus, rien n'est perdu.
|
||||
for tailStart > head && ((msgs[tailStart].Role != "user" && msgs[tailStart].Role != "assistant") ||
|
||||
isLokiInjected(msgs[tailStart])) {
|
||||
tailStart--
|
||||
}
|
||||
return head, tailStart
|
||||
@@ -379,7 +450,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
}
|
||||
}
|
||||
}
|
||||
summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
|
||||
summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
var mid []Message
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
||||
@@ -392,8 +463,18 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
// system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix).
|
||||
// Le message porte aussi la CONSCIENCE de la compaction et l'index des ids
|
||||
// rappelables (voir compactSummaryUserMsg).
|
||||
sumMsg := compactSummaryUserMsg(summary, index)
|
||||
// Mode Code : les critères ne vivent pas dans le fil (seuls les appels à
|
||||
// l'outil criteria y passent, et ils viennent d'être résumés). On rend
|
||||
// leur état au builder ici, dans un message de toute façon neuf — pas de
|
||||
// cache invalidé en plus (repris d'OpenFox).
|
||||
if caps.Code {
|
||||
if list := critList(convEnsureActive()); len(list) > 0 {
|
||||
sumMsg += "\n\nAcceptance criteria (current state):\n" + critRender(list)
|
||||
}
|
||||
}
|
||||
mid = []Message{
|
||||
{Role: "user", Content: compactSummaryUserMsg(summary, index)},
|
||||
{Role: "user", Content: sumMsg},
|
||||
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."},
|
||||
}
|
||||
}
|
||||
@@ -408,9 +489,28 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
// On réinjecte donc textuellement la dernière vraie demande du torse, juste
|
||||
// avant la queue (les résultats d'outils qu'elle a produits la suivent, comme
|
||||
// dans l'historique d'origine). Le torse reste entièrement compactable.
|
||||
//
|
||||
// Seulement quand c'est nécessaire (AJEAN 0.17.4), sinon deux bugs :
|
||||
// - la queue contient déjà un message `user` : la dernière demande du torse a
|
||||
// DÉJÀ reçu sa réponse, la réinjecter juste avant la nouvelle faisait
|
||||
// répondre le modèle une seconde fois à une vieille question ;
|
||||
// - repli sans résumé (mid = torse dégraissé) : ce message y est déjà, il se
|
||||
// retrouvait en double, cette fois APRÈS ses propres résultats d'outils.
|
||||
//
|
||||
// Un rappel de loki (budget d'outils, relance) a le rôle `user` sans être une
|
||||
// demande (isLokiInjected) : ni il ne prouve qu'une demande a été servie, ni
|
||||
// il ne se réinjecte à la place de la vraie.
|
||||
tailHasUser := false
|
||||
for _, m := range msgs[tailStart:] {
|
||||
if m.Role == "user" && !isLokiInjected(m) {
|
||||
tailHasUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
summarized := err == nil && !summaryLooksEmpty(summary)
|
||||
var pending []Message
|
||||
for i := len(torso) - 1; i >= 0; i-- {
|
||||
if torso[i].Role != "user" {
|
||||
for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- {
|
||||
if torso[i].Role != "user" || isLokiInjected(torso[i]) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) {
|
||||
@@ -531,6 +631,12 @@ func renderTranscript(msgs []Message) string {
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case "user":
|
||||
// Rappel de loki : une note du système, pas une demande que le
|
||||
// résumé devrait attribuer à l'utilisateur.
|
||||
if isLokiInjected(m) {
|
||||
fmt.Fprintf(&b, "System: %s\n", msgText(m))
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "User: %s\n", msgText(m))
|
||||
case "assistant":
|
||||
if t := msgText(m); t != "" {
|
||||
@@ -562,8 +668,10 @@ func renderTranscript(msgs []Message) string {
|
||||
type summarizeResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
@@ -571,6 +679,20 @@ type summarizeResp struct {
|
||||
// Un seul appel NON streamé, sans outils — comme Hermes, on réutilise le modèle
|
||||
// principal déjà chargé (aucune dépendance, cohérent avec la fenêtre de contexte).
|
||||
func summarizeTranscript(ctx context.Context, transcript string) (string, error) {
|
||||
return summarizeTranscriptFor(ctx, transcript, false)
|
||||
}
|
||||
|
||||
// codeSummaryRules : ce qu'un résumé de mode Code doit garder en plus — sans
|
||||
// quoi le builder réécrit des fichiers déjà faits et retombe dans des erreurs
|
||||
// déjà résolues (repris du COMPACTION_PROMPT d'OpenFox).
|
||||
const codeSummaryRules = `
|
||||
This is a CODING session. Also keep:
|
||||
- Every file created or modified (path) and what changed in it
|
||||
- Errors hit (build, tests, tools) and how each was resolved — or that it is still open
|
||||
- The commands that build and test the project`
|
||||
|
||||
// summarizeTranscriptFor : code = résumé d'une session du mode Code.
|
||||
func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) {
|
||||
sys := `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
|
||||
|
||||
The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
|
||||
@@ -583,6 +705,9 @@ Summarize densely and faithfully, keeping ONLY the essentials:
|
||||
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
|
||||
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
|
||||
Write the summary in the SAME language as the conversation.`
|
||||
if code {
|
||||
sys += codeSummaryRules
|
||||
}
|
||||
|
||||
// Le résumé part au MÊME endroit que le chat : sur un preset externe, il
|
||||
// s'appuie sur l'API distante (backend_external.go). Le laisser taper le
|
||||
@@ -605,6 +730,11 @@ Write the summary in the SAME language as the conversation.`
|
||||
// passe ces kwargs au gabarit Jinja (--jinja).
|
||||
"chat_template_kwargs": map[string]any{"enable_thinking": false},
|
||||
}
|
||||
if ep.External {
|
||||
// Propre à llama.cpp : une API distante stricte (OpenAI) refuse un
|
||||
// argument inconnu par un 400, et la compaction échouait à chaque fois.
|
||||
delete(payload, "chat_template_kwargs")
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -612,6 +742,9 @@ Write the summary in the SAME language as the conversation.`
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ep.auth(req.Header.Set)
|
||||
if !ep.External {
|
||||
defer engineRequestStart()() // voir llm_slots.go
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", friendlyLLMError(err)
|
||||
@@ -625,27 +758,209 @@ Write the summary in the SAME language as the conversation.`
|
||||
}
|
||||
return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out summarizeResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Decoder et non Unmarshal : comme avant, seule la première valeur JSON
|
||||
// compte — un octet parasite après elle (proxy, passerelle) ne doit pas
|
||||
// faire échouer une compaction qui passait.
|
||||
var out summarizeResp
|
||||
if err := json.NewDecoder(bytes.NewReader(raw)).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Télémétrie de la compaction : réponse non streamée, timings et usage sont
|
||||
// à la racine. Lue à part et sans échec possible (perfWire) : un compteur
|
||||
// mal typé ne doit jamais faire échouer la compaction.
|
||||
perfRecord(perfRecFromWire(perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv}, decodePerfWire(raw)), nil)
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("résumé: réponse vide")
|
||||
}
|
||||
c := out.Choices[0].Message.Content
|
||||
// Certains modèles à raisonnement préfixent un bloc <think>…</think> : on ne
|
||||
// garde que la réponse finale.
|
||||
if i := strings.LastIndex(c, thinkClose); i >= 0 {
|
||||
c = c[i+len(thinkClose):]
|
||||
ch := out.Choices[0]
|
||||
return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
|
||||
}
|
||||
|
||||
// summaryRuneCap borne le résumé en caractères, en filet de sécurité
|
||||
// seulement. L'ancienne borne fixe de 2200 caractères (≈ 550 tokens) coupait
|
||||
// un résumé que max_tokens autorisait à ~1600 tokens — et le prompt met l'ÉTAT
|
||||
// D'AVANCEMENT en dernier : c'était précisément lui, ce dont l'agent a besoin
|
||||
// pour reprendre, qui tombait. max_tokens borne déjà la sortie ; ×6 caractères
|
||||
// par token laisse passer tout résumé que le moteur a pu produire (le français
|
||||
// tourne autour de 4) et ne mord que sur une API qui ignorerait max_tokens.
|
||||
func summaryRuneCap() int {
|
||||
return compactSummaryBudget() * 6
|
||||
}
|
||||
|
||||
// cleanSummary extrait le texte final d'une réponse de résumé. Une erreur fait
|
||||
// retomber l'appelant sur le torse dégraissé, sans perte : bien mieux que
|
||||
// d'installer du raisonnement brut comme « mémoire » de la conversation.
|
||||
// - un bloc <think>…</think> en tête est retiré, seule la réponse compte ;
|
||||
// - un <think> ouvert en tête et jamais refermé (modèle qui ignore
|
||||
// enable_thinking=false et bute sur max_tokens), ou un texte vide à côté
|
||||
// d'un reasoning_content : il n'y a QUE du raisonnement, pas de résumé →
|
||||
// erreur ;
|
||||
// - finish_reason=length : le texte est gardé (le début d'un bon résumé vaut
|
||||
// mieux que rien), marqué « […] » et tracé dans le journal.
|
||||
func cleanSummary(content, reasoning, finish string) (string, error) {
|
||||
c := strings.TrimSpace(content)
|
||||
// Le raisonnement ne se retire qu'en TÊTE : soit un bloc <think>…</think>
|
||||
// qui ouvre la réponse, soit un </think> sans <think> avant lui (gabarit qui
|
||||
// ouvre le bloc dans le prompt). Couper au DERNIER </think>, comme avant,
|
||||
// amputait tout le début d'un résumé de session Code qui cite la balise (un
|
||||
// parseur, un gabarit) — et une balise citée après un <think> en milieu de
|
||||
// texte n'est pas du raisonnement non plus.
|
||||
for {
|
||||
i := strings.Index(c, thinkClose)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
if o := strings.Index(c[:i], "<think>"); o > 0 {
|
||||
break
|
||||
}
|
||||
c = strings.TrimSpace(c[i+len(thinkClose):])
|
||||
if !strings.HasPrefix(c, "<think>") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c = strings.TrimSpace(c)
|
||||
// Garde-fou dur : même si le modèle ignore la consigne de longueur, on tronque
|
||||
// pour garantir une vraie compression. 2200 caractères (≈ 550 tokens) et pas
|
||||
// 1500 : le résumé doit désormais porter les FAITS déjà trouvés, pas seulement
|
||||
// l'intention, sinon l'IA repart en recherche après chaque compactage. Coupé
|
||||
// sur une frontière de rune (é, … ne doivent pas devenir des �).
|
||||
if r := []rune(c); len(r) > 2200 {
|
||||
c = strings.TrimSpace(string(r[:2200])) + " […]"
|
||||
if strings.HasPrefix(c, "<think>") {
|
||||
return "", fmt.Errorf("résumé: raisonnement jamais refermé, aucun résumé")
|
||||
}
|
||||
if c == "" {
|
||||
if strings.TrimSpace(reasoning) != "" || strings.TrimSpace(content) != "" {
|
||||
return "", fmt.Errorf("résumé: rien hors du raisonnement")
|
||||
}
|
||||
return "", fmt.Errorf("résumé: texte vide")
|
||||
}
|
||||
cut := ""
|
||||
if r := []rune(c); len(r) > summaryRuneCap() {
|
||||
// Coupé sur une frontière de rune (é, … ne doivent pas devenir des �).
|
||||
c = strings.TrimSpace(string(r[:summaryRuneCap()]))
|
||||
cut = fmt.Sprintf("par le filet de %d caractères", summaryRuneCap())
|
||||
}
|
||||
if finish == "length" {
|
||||
cut = fmt.Sprintf("par max_tokens=%d", compactSummaryBudget())
|
||||
}
|
||||
if cut != "" {
|
||||
// Tracé dans les deux cas : le filet ne mord que sur une API qui
|
||||
// ignore max_tokens, et c'est l'ÉTAT D'AVANCEMENT qui tombe.
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé coupé %s (%d caractères gardés)\n", cut, len([]rune(c)))
|
||||
c += " […]"
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// contextOverflow : le refus du moteur (corps d'erreur msg) signale-t-il un
|
||||
// débordement de la fenêtre de contexte ? Reconnaît les libellés de llama.cpp
|
||||
// (« exceeds the available context size », exceed_context_size_error) et des API
|
||||
// OpenAI-compatibles (context_length_exceeded, « maximum context length »). À
|
||||
// défaut de libellé reconnu, on se fie à la taille estimée : une conversation
|
||||
// déjà à 90 % de la fenêtre a toutes les chances d'être la cause.
|
||||
func contextOverflow(msg string, msgs []Message) bool {
|
||||
m := strings.ToLower(msg)
|
||||
for _, k := range []string{"context size", "context_size", "context length", "context_length",
|
||||
"context window", "n_ctx", "too many tokens", "prompt is too long", "exceeds the context"} {
|
||||
if strings.Contains(m, k) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return estimateTokens(msgs) >= int(float64(ctxWindow())*0.9)
|
||||
}
|
||||
|
||||
// ---- dernier recours : la requête ne rentre toujours pas -------------------
|
||||
// Repris d'AJEAN 0.17.5.
|
||||
|
||||
var reqTokensRe = regexp.MustCompile(`request \((\d+) tokens\)`)
|
||||
|
||||
// overflowTokens extrait la taille réelle de la requête refusée par llama.cpp
|
||||
// (« request (132291 tokens) exceeds… »), 0 si absente.
|
||||
func overflowTokens(msg string) int {
|
||||
if m := reqTokensRe.FindStringSubmatch(msg); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const shrinkTruncMarker = "\n[…tronqué : contexte plein. Relance l'outil de façon plus ciblée si tu as besoin de la suite]"
|
||||
|
||||
// shrinkToFit est le filet quand le moteur a refusé le prompt ET que le
|
||||
// compactage n'a rien pu faire (torse vide, ou réduction jugée trop faible :
|
||||
// typiquement un tour qui a lu beaucoup de gros fichiers d'un coup, tout est
|
||||
// dans la queue protégée). Avant, l'erreur 400 remontait telle quelle et la
|
||||
// conversation restait bloquée. Ici on réduit pour de bon :
|
||||
// 1. les gros résultats d'outils (du plus ancien au plus récent) sont tronqués ;
|
||||
// 2. si ça ne suffit pas, on retire les plus vieux échanges, par tour entier
|
||||
// (frontière `user`), en gardant les messages système de tête.
|
||||
//
|
||||
// La cible vise 60 % de la fenêtre, corrigée par l'écart entre estimation et
|
||||
// taille réelle (actual, lue dans l'erreur) : l'estimation sous-compte (images…).
|
||||
func shrinkToFit(msgs []Message, actual int) ([]Message, bool) {
|
||||
est := estimateTokens(msgs)
|
||||
if est <= 0 {
|
||||
return msgs, false
|
||||
}
|
||||
ratio := 1.0
|
||||
if actual > est {
|
||||
ratio = float64(actual) / float64(est)
|
||||
}
|
||||
target := int(float64(ctxWindow()) * 0.6 / ratio)
|
||||
out := append([]Message(nil), msgs...)
|
||||
for i := range out {
|
||||
if estimateTokens(out) <= target {
|
||||
break
|
||||
}
|
||||
if out[i].Role != "tool" {
|
||||
continue
|
||||
}
|
||||
if r := []rune(msgText(out[i])); len(r) > 2000 {
|
||||
out[i].Content = string(r[:1500]) + shrinkTruncMarker
|
||||
}
|
||||
}
|
||||
head := 0
|
||||
for head < len(out) && out[head].Role == "system" {
|
||||
head++
|
||||
}
|
||||
for estimateTokens(out) > target {
|
||||
// Coupe d'abord aux vraies demandes : couper sur un rappel de loki
|
||||
// jetterait la demande en gardant la fin de sa boucle d'outils. Un
|
||||
// rappel ne sert de coupe qu'en dernier recours, quand il n'y a plus
|
||||
// aucune demande après la tête (une seule longue boucle d'outils) —
|
||||
// comme avant qu'ils soient persistés.
|
||||
next, nudge := -1, -1
|
||||
for k := head + 1; k < len(out); k++ {
|
||||
if out[k].Role != "user" {
|
||||
continue
|
||||
}
|
||||
if !isLokiInjected(out[k]) {
|
||||
next = k
|
||||
break
|
||||
}
|
||||
if nudge < 0 {
|
||||
nudge = k
|
||||
}
|
||||
}
|
||||
if next < 0 {
|
||||
next = nudge
|
||||
}
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
out = append(out[:head:head], out[next:]...)
|
||||
}
|
||||
return out, estimateTokens(out) < est
|
||||
}
|
||||
|
||||
// toolResultMax borne TOUT résultat d'outil dans la vue du modèle (≈ 8k
|
||||
// tokens). Le terminal et le web ont déjà leur propre plafond, plus bas ;
|
||||
// celui-ci rattrape les autres (lecture de fichier, pages mémoire, MCP…) : un
|
||||
// seul résultat géant suffisait à faire déborder une fenêtre de 65k d'un coup,
|
||||
// sans que le compactage puisse rien y faire. L'UI, elle, garde le résultat
|
||||
// complet (« voir plus », tool_results.go).
|
||||
const toolResultMax = 30000
|
||||
|
||||
func capToolResult(s string) string {
|
||||
if r := []rune(s); len(r) > toolResultMax {
|
||||
return string(r[:toolResultMax]) + "\n[…tronqué : résultat trop long. Cible une partie plus précise si tu as besoin de la suite]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le compactage de secours ne doit se déclencher que sur un vrai débordement de
|
||||
// contexte, pas sur n'importe quel refus du moteur.
|
||||
func TestContextOverflow(t *testing.T) {
|
||||
small := []Message{{Role: "user", Content: "salut"}}
|
||||
yes := []string{
|
||||
`{"error":{"code":400,"message":"request (70000 tokens) exceeds the available context size (65536 tokens), try increasing it","type":"exceed_context_size_error"}}`,
|
||||
`{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}`,
|
||||
}
|
||||
no := []string{
|
||||
`{"error":{"code":500,"message":"Failed to parse tool call arguments as JSON","type":"server_error"}}`,
|
||||
`{"error":{"code":503,"message":"Loading model","type":"unavailable_error"}}`,
|
||||
`Unable to generate parser for this template`,
|
||||
}
|
||||
for _, m := range yes {
|
||||
if !contextOverflow(m, small) {
|
||||
t.Errorf("débordement non reconnu : %s", m)
|
||||
}
|
||||
}
|
||||
for _, m := range no {
|
||||
if contextOverflow(m, small) {
|
||||
t.Errorf("faux débordement (déclencherait un compactage) : %s", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le dernier recours ramène sous la fenêtre même quand tout est dans un seul
|
||||
// tour (AJEAN 0.17.5).
|
||||
func TestShrinkToFit(t *testing.T) {
|
||||
testHome(t)
|
||||
big := strings.Repeat("x", 200000)
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: "ctx"},
|
||||
{Role: "user", Content: "vieux"},
|
||||
{Role: "assistant", Content: "ok"},
|
||||
{Role: "user", Content: "lis tout"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "a"}}},
|
||||
{Role: "tool", ToolCallID: "a", Content: big},
|
||||
{Role: "tool", ToolCallID: "b", Content: big},
|
||||
}
|
||||
out, changed := shrinkToFit(msgs, 132291)
|
||||
if !changed || out[0].Role != "system" {
|
||||
t.Fatalf("pas réduit : %v", changed)
|
||||
}
|
||||
if est := estimateTokens(out); est > int(float64(ctxWindow())*0.6) {
|
||||
t.Fatalf("encore trop gros : %d", est)
|
||||
}
|
||||
if overflowTokens(`{"message":"request (132291 tokens) exceeds"}`) != 132291 {
|
||||
t.Fatal("taille réelle non lue")
|
||||
}
|
||||
}
|
||||
|
||||
// Un résultat d'outil géant est borné pour le modèle, avec une mention qui dit
|
||||
// au modèle de cibler plus précisément.
|
||||
func TestCapToolResult(t *testing.T) {
|
||||
if got := capToolResult("court"); got != "court" {
|
||||
t.Fatalf("résultat court modifié : %q", got)
|
||||
}
|
||||
got := capToolResult(strings.Repeat("é", toolResultMax+500))
|
||||
if r := []rune(got); len(r) > toolResultMax+200 || !strings.Contains(got, "tronqué") {
|
||||
t.Fatalf("résultat géant mal borné (%d runes)", len(r))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -177,3 +183,149 @@ func TestEstimateTokensGrows(t *testing.T) {
|
||||
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
|
||||
}
|
||||
}
|
||||
|
||||
// Repli sans résumé (moteur absent) : la demande en cours est déjà dans le
|
||||
// torse dégraissé, elle ne doit pas être réinjectée une seconde fois — elle
|
||||
// apparaissait APRÈS ses propres résultats d'outils (AJEAN 0.17.4).
|
||||
func TestCompactSansResumeNeDoublePasLaDemande(t *testing.T) {
|
||||
testHome(t)
|
||||
page := func(n int) Message {
|
||||
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
||||
}
|
||||
msgs := []Message{
|
||||
um("première question de la conversation"), am("ok"),
|
||||
um("cherche les horaires du train pour Lyon"),
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("web_read"), page(i))
|
||||
}
|
||||
out, _ := compactMessages(t.Context(), msgs, Caps{})
|
||||
n := 0
|
||||
for _, m := range out {
|
||||
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("demande en cours présente %d fois après compactage, attendu 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode Code : le résumé demande les fichiers touchés et les erreurs, et l'état
|
||||
// des critères est rendu au builder après compactage.
|
||||
func TestCompactageModeCodeGardeLEtat(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"les tests passent"}}, false)
|
||||
var sys string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
sys = msgText(body.Messages[0])
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- main.go modifié : la fonction parse corrigée, go build et go test passent"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := func(n int) Message {
|
||||
return tm("sortie de commande " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
||||
}
|
||||
msgs := []Message{um("corrige le build"), am("ok"), um("continue")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("bash"), page(i))
|
||||
}
|
||||
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true, Code: true})
|
||||
if !changed {
|
||||
t.Fatal("pas compacté")
|
||||
}
|
||||
if !strings.Contains(sys, "CODING session") {
|
||||
t.Fatal("consigne de résumé du mode Code absente")
|
||||
}
|
||||
found := false
|
||||
for _, m := range out {
|
||||
if strings.Contains(msgText(m), "Acceptance criteria (current state)") && strings.Contains(msgText(m), "les tests passent") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("critères non rendus après compactage")
|
||||
}
|
||||
}
|
||||
|
||||
// Le résumé n'est plus coupé à 2200 caractères : max_tokens borne déjà la
|
||||
// sortie, et l'ancienne coupe faisait tomber l'ÉTAT D'AVANCEMENT, écrit en
|
||||
// dernier. Seul le raisonnement sans résumé est refusé (repli sur le torse).
|
||||
func TestCleanSummary(t *testing.T) {
|
||||
testHome(t)
|
||||
// Fenêtre fixée : le filet (budget×6) en dépend, le cas « long » ne doit pas
|
||||
// basculer au-delà si la valeur par défaut de CTX change.
|
||||
if err := SetConfigKey("CTX", "65536"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
long := strings.Repeat("é", 7000) + " ÉTAT D'AVANCEMENT"
|
||||
huge := strings.Repeat("x", summaryRuneCap()+500)
|
||||
cases := []struct {
|
||||
name, content, reasoning, finish string
|
||||
want string
|
||||
err bool
|
||||
}{
|
||||
{"simple", " - fait A\n- fait B ", "", "stop", "- fait A\n- fait B", false},
|
||||
{"long gardé entier", long, "", "stop", long, false},
|
||||
{"think retiré", "<think>je réfléchis</think>\n- résumé", "", "stop", "- résumé", false},
|
||||
{"length gardé et marqué", "- début du résumé", "", "length", "- début du résumé […]", false},
|
||||
{"au-delà du filet", huge, "", "stop", strings.Repeat("x", summaryRuneCap()) + " […]", false},
|
||||
{"balise citée en milieu", "- le parseur retire <think> en tête", "", "stop", "- le parseur retire <think> en tête", false},
|
||||
{"bloc cité en milieu gardé entier", "- A\n- le parseur retire <think>x</think> en tête\n- B", "", "stop", "- A\n- le parseur retire <think>x</think> en tête\n- B", false},
|
||||
{"think retiré, balise citée après", "<think>r</think>\n- A\n- le parseur coupe à </think>\n- B", "", "stop", "- A\n- le parseur coupe à </think>\n- B", false},
|
||||
{"ouverture dans le prompt", "je réfléchis</think>\n- résumé", "", "stop", "- résumé", false},
|
||||
{"deux blocs en tête", "<think>a</think><think>b</think>\n- résumé", "", "stop", "- résumé", false},
|
||||
{"second bloc jamais refermé", "<think>a</think>\n<think>b", "", "length", "", true},
|
||||
{"think jamais refermé", "<think>je réfléchis encore", "", "length", "", true},
|
||||
{"que du reasoning_content", "", "je réfléchis", "length", "", true},
|
||||
{"think vide de réponse", "<think>x</think> ", "", "stop", "", true},
|
||||
{"vide", "", "", "stop", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := cleanSummary(c.content, c.reasoning, c.finish)
|
||||
if (err != nil) != c.err || got != c.want {
|
||||
t.Errorf("%s : %q, %v", c.name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bout à bout : un résumé de 7000 caractères revient intact du moteur, et une
|
||||
// réponse qui n'a que du raisonnement est une erreur (repli de l'appelant).
|
||||
func TestSummarizeLongNotTruncated(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := SetConfigKey("CTX", "65536"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
long := strings.Repeat("ü", 7000)
|
||||
// Réponse changée entre deux requêtes : derrière un mutex, le handler tourne
|
||||
// dans une goroutine du serveur.
|
||||
var mu sync.Mutex
|
||||
var reply map[string]any
|
||||
setReply := func(r map[string]any) { mu.Lock(); reply = r; mu.Unlock() }
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(reply)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setReply(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": long}, "finish_reason": "stop"}}})
|
||||
got, err := summarizeTranscriptFor(context.Background(), "transcript", false)
|
||||
if err != nil || got != long {
|
||||
t.Fatalf("résumé tronqué : %d caractères, %v", len([]rune(got)), err)
|
||||
}
|
||||
setReply(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "", "reasoning_content": "hmm"}, "finish_reason": "length"}}})
|
||||
if got, err := summarizeTranscriptFor(context.Background(), "transcript", false); err == nil {
|
||||
t.Fatalf("raisonnement seul accepté comme résumé : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package loki
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -58,6 +59,14 @@ type Conversation struct {
|
||||
Log []LogEvent `json:"log"` // vue « UI » rejouable
|
||||
Seq int `json:"seq"`
|
||||
CtxUsed int `json:"ctx_used"` // taille réelle du contexte au dernier tour
|
||||
// ctxUsedLen : nombre de messages de Messages que CtxUsed couvre déjà (0 =
|
||||
// inconnu). Ce qui arrive ensuite — le message utilisateur du tour suivant,
|
||||
// la file — est estimé en plus par ctxPending : le moteur ne l'a pas encore
|
||||
// compté. genPeak : génération la plus longue du dernier tour (llama-server
|
||||
// local), pour la garde de marge de compactNeeded. Ni l'un ni l'autre n'est
|
||||
// persisté : un rechargement repart du seul CtxUsed, comme avant.
|
||||
ctxUsedLen int
|
||||
genPeak int
|
||||
|
||||
Generating bool `json:"-"`
|
||||
cancel context.CancelFunc // annule la génération en cours (/stop)
|
||||
@@ -68,6 +77,30 @@ type Conversation struct {
|
||||
// à une génération fantôme dans la discussion ouverte.
|
||||
runningTaskID string
|
||||
runningTaskName string
|
||||
// benching : le verrou est tenu par un benchmark (llm_bench_job.go). Même
|
||||
// logique que la tâche : l'interface et les refus disent qui occupe le moteur.
|
||||
benching bool
|
||||
|
||||
// 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
|
||||
// d'étape (après un appel d'outil), ou — si le tour se termine avant —
|
||||
// traités comme tours suivants, dans l'ordre. Protégée par mu.
|
||||
queued []queuedMsg
|
||||
// recentCIDs : identifiants d'envoi déjà acceptés. L'UI réessaie un envoi
|
||||
// dont la réponse s'est perdue (tunnel) : avant la file, le 409 « déjà en
|
||||
// cours » faisait office de dédoublonnage ; sans lui, le réessai mettrait
|
||||
// le même message deux fois en file.
|
||||
recentCIDs []string
|
||||
}
|
||||
|
||||
// queuedMsg = un message mis en file pendant la génération. Caps et
|
||||
// température sont ceux choisis à l'envoi : un tour démarré depuis la file doit
|
||||
// s'exécuter avec ces réglages-là.
|
||||
type queuedMsg struct {
|
||||
text string
|
||||
files []attachInfo
|
||||
caps Caps
|
||||
temp float64
|
||||
}
|
||||
|
||||
var conv = func() *Conversation {
|
||||
@@ -128,7 +161,11 @@ func LoadConversation() {
|
||||
}
|
||||
// convEnsureActive reprend au passage le fil unique des versions
|
||||
// précédentes (clé « conversation ») comme première discussion.
|
||||
if b, err = getBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
|
||||
// getStoreBytesErr : même lecture, en DÉCHIFFRANT au besoin (mémoire
|
||||
// chiffrée). Verrouillé, elle rend (nil, nil) — pas une erreur d'accès :
|
||||
// on démarre alors sur un fil vide sans rien écraser, et tout revient au
|
||||
// déverrouillage.
|
||||
if b, err = getStoreBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
|
||||
break
|
||||
}
|
||||
retry()
|
||||
@@ -149,6 +186,7 @@ func LoadConversation() {
|
||||
// Guérit les conversations d'avant le passage des captures en éphémère : un
|
||||
// base64 d'image persisté était rejoué à chaque tour et dépassait le contexte.
|
||||
conv.Messages = stripImageParts(conv.Messages)
|
||||
go pruneChatImages()
|
||||
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
|
||||
conv.Generating = false
|
||||
conv.cancel = nil
|
||||
@@ -156,43 +194,50 @@ func LoadConversation() {
|
||||
|
||||
// loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil
|
||||
// neuf) et invalide les abonnés : l'epoch incrémenté leur fait vider l'écran et
|
||||
// rejouer depuis zéro, exactement comme un reset. L'appelant NE doit PAS
|
||||
// détenir mu.
|
||||
func (c *Conversation) loadFrom(b []byte) {
|
||||
c.Stop()
|
||||
// rejouer depuis zéro, exactement comme un reset. id = discussion chargée : le
|
||||
// cache de la discussion active change sous mu, avec le contenu (voir
|
||||
// convActivate). L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) loadFrom(id string, b []byte) {
|
||||
// Un benchmark n'appartient à aucune discussion : changer de fil ne l'arrête
|
||||
// pas, et le verrou reste à lui jusqu'à sa fin (llm_bench_job.go).
|
||||
c.mu.Lock()
|
||||
bench := c.benching
|
||||
c.mu.Unlock()
|
||||
if !bench {
|
||||
c.Stop()
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c == conv {
|
||||
convActiveRemember(id)
|
||||
}
|
||||
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
|
||||
c.ctxUsedLen, c.genPeak = 0, 0
|
||||
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
|
||||
if len(b) > 0 {
|
||||
_ = json.Unmarshal(b, c)
|
||||
c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement
|
||||
}
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
go pruneChatImages() // plus rien ne référence les images de l'ancien fil
|
||||
if !c.benching {
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
}
|
||||
c.epoch++
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
|
||||
// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit
|
||||
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) persist() {
|
||||
c.mu.Lock()
|
||||
b, err := json.Marshal(c)
|
||||
title := convSummary(c.Messages)
|
||||
turns := 0
|
||||
for _, ev := range c.Log {
|
||||
if _, ok := ev.Delta["user"]; ok {
|
||||
turns++
|
||||
}
|
||||
// reloadEncryptedStores recharge la discussion depuis le disque après un
|
||||
// déverrouillage : verrouillée, la lecture au démarrage a rendu un fil vide.
|
||||
// On ne recharge QUE dans ce cas — écraser un fil déjà rempli ferait perdre le
|
||||
// tour en cours, qui n'a pas pu être persisté.
|
||||
func reloadEncryptedStores() {
|
||||
conv.mu.Lock()
|
||||
empty := len(conv.Log) == 0
|
||||
conv.mu.Unlock()
|
||||
if empty {
|
||||
LoadConversation()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
_ = putBytes(bkChat, convKey(id), b)
|
||||
convTouchMeta(id, title, turns)
|
||||
}
|
||||
|
||||
// appendDelta journalise un événement d'affichage et réveille les abonnés.
|
||||
@@ -246,8 +291,16 @@ func evTS0(d map[string]any, fallback int64) int64 {
|
||||
// On préserve `toks` (somme) et `ts0` (premier) pour que le compteur de vitesse
|
||||
// (tok/s) reste correct au replay. Verrou détenu par l'appelant.
|
||||
func (c *Conversation) compactLogLocked() {
|
||||
if len(c.Log) < 2 {
|
||||
return
|
||||
c.Log = compactLog(c.Log)
|
||||
}
|
||||
|
||||
// compactLog est la compaction de compactLogLocked sous forme PURE : elle rend
|
||||
// un nouveau journal sans toucher à celui qu'on lui passe (les événements non
|
||||
// fusionnés sont partagés, pas recopiés). snapshot s'en sert pour écrire, en
|
||||
// plein tour, la forme qu'aura le journal à la fin du tour.
|
||||
func compactLog(log []LogEvent) []LogEvent {
|
||||
if len(log) < 2 {
|
||||
return log
|
||||
}
|
||||
textKey := func(d map[string]any) string {
|
||||
if _, ok := d["content"].(string); ok {
|
||||
@@ -258,7 +311,7 @@ func (c *Conversation) compactLogLocked() {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
out := make([]LogEvent, 0, len(c.Log))
|
||||
out := make([]LogEvent, 0, len(log))
|
||||
var buf strings.Builder
|
||||
bufKey := ""
|
||||
var cur LogEvent
|
||||
@@ -275,7 +328,7 @@ func (c *Conversation) compactLogLocked() {
|
||||
buf.Reset()
|
||||
bufKey, toks, ts0, seq0 = "", 0, 0, 0
|
||||
}
|
||||
for _, ev := range c.Log {
|
||||
for _, ev := range log {
|
||||
// Outils : un appel s'écrit en de nombreux événements (annonce done=false,
|
||||
// frappe du corps, streaming des arguments) jusqu'au done=true, qui porte
|
||||
// déjà l'état FINAL (résultat + diff). Les intermédiaires ne servent qu'à
|
||||
@@ -311,7 +364,7 @@ func (c *Conversation) compactLogLocked() {
|
||||
toks += evToks(ev.Delta)
|
||||
}
|
||||
flush()
|
||||
c.Log = out
|
||||
return out
|
||||
}
|
||||
|
||||
// compactAndPublish exécute UNE compaction et en publie tout le cycle de vie :
|
||||
@@ -338,6 +391,12 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
c.appendDelta(epoch, map[string]any{"compact_noop": true})
|
||||
return msgs, false
|
||||
}
|
||||
// Les pages mémoire lues ont été résumées avec le torse : un rappel liste
|
||||
// leurs noms pour que le modèle les relise au besoin (AJEAN 0.14.0). Agent
|
||||
// seulement — sans lui, pas d'outil mem_read pour y donner suite.
|
||||
if caps.Agent {
|
||||
compacted = remindReadMemPages(compacted, msgs)
|
||||
}
|
||||
overhead := ctxUsed - estimateTokens(msgs)
|
||||
if overhead < 0 {
|
||||
overhead = 0
|
||||
@@ -347,6 +406,7 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
if c.epoch == epoch {
|
||||
c.Messages = compacted
|
||||
c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour
|
||||
c.ctxUsedLen = len(compacted)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(epoch, map[string]any{"compacted": true})
|
||||
@@ -395,8 +455,11 @@ var ErrBusy = fmt.Errorf("génération en cours")
|
||||
// nomme la tâche et on dit comment reprendre la main.
|
||||
func (c *Conversation) busyReason() error {
|
||||
c.mu.Lock()
|
||||
name := c.runningTaskName
|
||||
name, bench := c.runningTaskName, c.benching
|
||||
c.mu.Unlock()
|
||||
if bench {
|
||||
return errBenchBusy
|
||||
}
|
||||
if name == "" {
|
||||
return ErrBusy
|
||||
}
|
||||
@@ -447,9 +510,10 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
|
||||
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite :
|
||||
// si le process meurt en pleine génération (crash, restart après MAJ), le
|
||||
// message de l'utilisateur survit au lieu de disparaître avec le tour.
|
||||
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite
|
||||
// (sur disque quelques ms plus tard, voir persistAsync) : si le process
|
||||
// meurt en pleine génération (crash, restart après MAJ), le message de
|
||||
// l'utilisateur survit au lieu de disparaître avec le tour.
|
||||
delta := map[string]any{"user": text}
|
||||
// Nom du modèle qui va produire la réponse : journalisé avec la borne de
|
||||
// tour, donc rejoué au chargement — la carte de réponse garde son modèle
|
||||
@@ -467,7 +531,9 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
if !caps.Code {
|
||||
maybeCodeHint(c, epoch, text)
|
||||
}
|
||||
c.persist()
|
||||
// Instantané pris ici, écriture laissée à l'écrivain (chat_persist.go) : le
|
||||
// fsync ne retarde plus le départ de la requête vers le modèle.
|
||||
c.persistAsync()
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
}
|
||||
@@ -475,10 +541,154 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrDupSend : cet envoi (même cid) a déjà été accepté — réessai d'un client
|
||||
// qui n'a pas reçu la réponse. Ce n'est pas une erreur pour l'utilisateur.
|
||||
var ErrDupSend = fmt.Errorf("envoi déjà reçu")
|
||||
|
||||
// seenCID enregistre cid et dit s'il avait déjà été vu. Appelant : mu tenu.
|
||||
func (c *Conversation) seenCIDLocked(cid string) bool {
|
||||
if cid == "" {
|
||||
return false
|
||||
}
|
||||
for _, v := range c.recentCIDs {
|
||||
if v == cid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
c.recentCIDs = append(c.recentCIDs, cid)
|
||||
if len(c.recentCIDs) > 64 {
|
||||
c.recentCIDs = c.recentCIDs[len(c.recentCIDs)-64:]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnqueueOrStart démarre un tour tout de suite si le moteur est libre, sinon
|
||||
// MET EN FILE le message (AJEAN 0.14.0) — au lieu du 409 d'avant, qui obligeait
|
||||
// à arrêter la réponse pour ajouter une précision. Seul un tour de CHAT accepte
|
||||
// une file : une tâche planifiée ou un benchmark qui occupe le modèle garde le
|
||||
// refus (busyReason), sa fin ne dépile rien. cid = identifiant de l'envoi (voir
|
||||
// recentCIDs) ; un doublon renvoie ErrDupSend.
|
||||
func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps Caps, temperature float64) (bool, error) {
|
||||
c.mu.Lock()
|
||||
if c.seenCIDLocked(cid) {
|
||||
c.mu.Unlock()
|
||||
return false, ErrDupSend
|
||||
}
|
||||
if c.Generating && c.runningTaskName == "" && !c.benching {
|
||||
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
|
||||
c.mu.Unlock()
|
||||
return true, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
err := c.StartTurn(text, files, caps, temperature)
|
||||
if errors.Is(err, ErrBusy) {
|
||||
// Un autre tour a démarré entre le test ci-dessus et StartTurn (deux
|
||||
// appareils qui envoient en même temps) : en file plutôt qu'un refus
|
||||
// (AJEAN 0.17.4). Une tâche planifiée garde le refus : sa fin ne dépile rien.
|
||||
c.mu.Lock()
|
||||
if c.Generating && c.runningTaskName == "" && !c.benching {
|
||||
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
|
||||
c.mu.Unlock()
|
||||
// Ce tour a pu finir entre-temps : sans relance, le message attendrait
|
||||
// le prochain envoi.
|
||||
c.startQueuedIfAny()
|
||||
return true, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
if err != nil {
|
||||
// Refusé (modèle pas prêt, tâche en cours) : le client pourra renvoyer
|
||||
// ce même message, son cid ne doit pas rester marqué comme reçu.
|
||||
c.mu.Lock()
|
||||
for i, v := range c.recentCIDs {
|
||||
if v == cid {
|
||||
c.recentCIDs = append(c.recentCIDs[:i], c.recentCIDs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// drainQueued vide la file et renvoie les messages utilisateur à injecter dans
|
||||
// le tour EN COURS (vue modèle), en les journalisant pour qu'ils apparaissent
|
||||
// dans le fil sur tous les appareils. Appelé par runChat entre deux étapes.
|
||||
// epoch garde contre un reset survenu entre-temps.
|
||||
func (c *Conversation) drainQueued(epoch int) []Message {
|
||||
c.mu.Lock()
|
||||
if c.epoch != epoch || len(c.queued) == 0 {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
items := c.queued
|
||||
c.queued = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
var out []Message
|
||||
for _, q := range items {
|
||||
delta := map[string]any{"user": q.text}
|
||||
if m := chatModelName(); m != "" {
|
||||
delta["model"] = m
|
||||
}
|
||||
if len(q.files) > 0 {
|
||||
delta["files"] = q.files
|
||||
}
|
||||
c.appendDelta(epoch, delta)
|
||||
prompt := q.text
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "Prends-en connaissance."
|
||||
}
|
||||
out = append(out, Message{Role: "user", Content: userMessageContent(q.files, prompt)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// startQueuedIfAny démarre le prochain tour depuis la file, s'il en reste et
|
||||
// que le moteur est libre. Appelé à la toute fin d'un tour : les messages
|
||||
// envoyés après la dernière frontière d'étape sont ainsi traités sans que
|
||||
// l'utilisateur ait à les renvoyer. Chaque tour lancé ainsi rappelle
|
||||
// startQueuedIfAny à sa fin → la file se vide dans l'ordre. Renvoie true si un
|
||||
// tour a démarré.
|
||||
func (c *Conversation) startQueuedIfAny() bool {
|
||||
c.mu.Lock()
|
||||
if c.Generating || len(c.queued) == 0 {
|
||||
c.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
q := c.queued[0]
|
||||
c.queued = c.queued[1:]
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
if err := c.StartTurn(q.text, q.files, q.caps, q.temp); err != nil {
|
||||
// Modèle indisponible au moment de dépiler (rare) : on le dit dans le
|
||||
// fil plutôt que de perdre le message en silence, et on tente le suivant.
|
||||
c.appendDelta(epoch, map[string]any{"error": "message en attente non envoyé : " + err.Error()})
|
||||
return c.startQueuedIfAny()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// dropQueued abandonne la file (bouton stop : l'utilisateur reprend la main) et
|
||||
// le signale au client, qui retire ses messages « en attente ».
|
||||
func (c *Conversation) dropQueued(epoch int) {
|
||||
c.mu.Lock()
|
||||
n := len(c.queued)
|
||||
c.queued = nil
|
||||
c.mu.Unlock()
|
||||
if n > 0 {
|
||||
c.appendDelta(epoch, map[string]any{"queue_dropped": n})
|
||||
}
|
||||
}
|
||||
|
||||
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
|
||||
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
|
||||
func chatModelName() string {
|
||||
m := strings.TrimSpace(ReadConfig()["MODEL"])
|
||||
cfg := ReadConfig()
|
||||
if isExternalConfig(cfg) {
|
||||
return strings.TrimSpace(cfg[extKeyModel]) // modèle distant, tel quel
|
||||
}
|
||||
m := strings.TrimSpace(cfg["MODEL"])
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -497,6 +707,7 @@ func chatModelName() string {
|
||||
// tout ce que ce tour produirait ensuite (deltas, messages, persistance) est
|
||||
// abandonné au lieu de ressusciter des morceaux de l'ancienne conversation.
|
||||
func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
turnStart := time.Now()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
stale := c.epoch != epoch
|
||||
@@ -518,21 +729,60 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
// File d'attente : interrompu par un stop, l'utilisateur reprend la main
|
||||
// et les messages en file sont abandonnés. Sinon on démarre le prochain
|
||||
// tour depuis la file — ceux qui n'ont pas pu être injectés en cours de
|
||||
// route sont traités, dans l'ordre.
|
||||
if ctx.Err() != nil {
|
||||
c.dropQueued(epoch)
|
||||
return
|
||||
}
|
||||
// Notification Web Push, seulement quand plus rien ne suit : un message
|
||||
// en file qui démarre aussitôt n'est pas une « réponse prête », et sa
|
||||
// propre fin notifiera. Ce chemin (generate) ne sert QUE les tours
|
||||
// utilisateur — les tâches de fond passent par RunAutonomous et ont leur
|
||||
// propre notification — donc pas de doublon. Détaché : l'envoi HTTP vers
|
||||
// le service de push ne doit pas retenir la fin du tour. Corps générique
|
||||
// (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas
|
||||
// de notification après un « stop » (retour ci-dessus) : l'utilisateur
|
||||
// est là et a coupé volontairement.
|
||||
if !c.startQueuedIfAny() && hasPushSubs() {
|
||||
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
|
||||
}
|
||||
}()
|
||||
// Télémétrie : tout ce que ce tour envoie au moteur (étapes, compaction,
|
||||
// sous-agents, vérification) est rattaché à la discussion active.
|
||||
ctx = withPerf(ctx, perfMain, convActiveID())
|
||||
|
||||
// llama-server local seulement : le preset externe garde le seul seuil, et
|
||||
// ses complétions ne nourrissent pas la garde de marge (compactNeeded).
|
||||
local := !externalActive()
|
||||
// Snapshot de la vue modèle.
|
||||
c.mu.Lock()
|
||||
msgs := append([]Message(nil), c.Messages...)
|
||||
ctxUsed := c.CtxUsed
|
||||
// Dernier compte du moteur + ce qu'il n'a pas encore vu (ce message-ci, la
|
||||
// file). Sans ce complément, seul le raisonnement compté à tort masquait le
|
||||
// manque ; maintenant qu'il est retiré, le nouveau message doit compter.
|
||||
ctxUsed := c.ctxNowLocked(msgs, local)
|
||||
peak := c.genPeak
|
||||
c.genPeak = 0 // recompté pendant ce tour
|
||||
c.mu.Unlock()
|
||||
|
||||
// Compaction proactive (façon Hermes) sur la vue MODÈLE uniquement ; le journal
|
||||
// d'affichage garde le fil complet. Le résumé est un appel modèle non streamé :
|
||||
// il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la
|
||||
// bannière de progression émise par compactAndPublish.
|
||||
if compactWouldTrigger(msgs, ctxUsed) {
|
||||
// Estimation de début de tour, comparée au premier compte réel du moteur
|
||||
// (journal [ctx], seulement au-delà de 2 % d'écart).
|
||||
startEst := ctxUsed
|
||||
if compactNeeded(msgs, ctxUsed, peak) {
|
||||
if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed {
|
||||
msgs = out
|
||||
// Le chiffre d'avant compaction ne dit plus rien de la requête qui
|
||||
// part : on compare celui que la compaction vient de poser.
|
||||
c.mu.Lock()
|
||||
startEst = c.CtxUsed
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,9 +792,20 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// Contexte du projet actif (description, index mémoire, index des trackers),
|
||||
// même traitement : injecté dans la vue envoyée, jamais persisté — voir
|
||||
// projectSystemMessages.
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
//
|
||||
// Mode agent OFF = modèle BRUT (AJEAN 0.13.13) : ni l'un ni l'autre. Sans
|
||||
// agent, on veut parler au modèle nu, comme à un llama-server direct. Le
|
||||
// prompt du preset et l'index mémoire décrivent des outils que ce mode n'a
|
||||
// pas : le modèle se mettait à écrire des <tool_call> en clair.
|
||||
final := msgs
|
||||
if caps.Agent {
|
||||
final = append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
}
|
||||
|
||||
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
|
||||
@@ -558,11 +819,21 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
|
||||
// déjà la compaction), approximative mais jamais absente.
|
||||
sawUsage := false
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
turnPeak := 0
|
||||
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
|
||||
// pas de vérification du mode Code avant sa réponse.
|
||||
asked := false
|
||||
sent, tools := prepareTurn(final, caps)
|
||||
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||
case ev.ToolUsed != nil:
|
||||
// Le texte écrit AVANT cet appel d'outil est déjà rangé dans le message
|
||||
// assistant porteur des tool_calls (extra). Le garder aussi dans la
|
||||
// réponse finale le doublait dans l'historique vu par le modèle — du
|
||||
// contexte gaspillé à chaque tour d'outil (AJEAN 0.17.4).
|
||||
content.Reset()
|
||||
tu := map[string]any{
|
||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||
@@ -571,15 +842,28 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// ne l'ajoute que quand il est là pour ne pas gonfler chaque événement.
|
||||
if ev.ToolUsed.Body != "" {
|
||||
tu["body"] = ev.ToolUsed.Body
|
||||
tu["body_lines"] = ev.ToolUsed.BodyLines
|
||||
if ev.ToolUsed.BodyTail {
|
||||
tu["body_tail"] = true
|
||||
}
|
||||
}
|
||||
// Lignes +/- d'une écriture (edit / mémoire) : persistées avec le reste
|
||||
// pour que le diff soit encore là après un rafraîchissement.
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
tu["added"] = ev.ToolUsed.Added
|
||||
tu["removed"] = ev.ToolUsed.Removed
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
}
|
||||
if ev.ToolUsed.ResultID != "" {
|
||||
tu["result_id"] = ev.ToolUsed.ResultID
|
||||
}
|
||||
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
|
||||
@@ -597,19 +881,27 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// compaction de début de tour.
|
||||
c.appendDelta(epoch, map[string]any{"compacting": *ev.Compacting})
|
||||
case ev.Stats != nil:
|
||||
// Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur
|
||||
// et la décision de compactage au tour suivant.
|
||||
// Taille réelle du contexte (usage.prompt_tokens + généré, raisonnement
|
||||
// non renvoyé retiré) pour le compteur et la décision de compactage au
|
||||
// tour suivant.
|
||||
if ev.Stats.PromptTokensTotal > 0 {
|
||||
if !sawUsage && local && startEst > 0 {
|
||||
logCtxEstimate("début-tour", startEst, ev.Stats.PromptTokensTotal)
|
||||
}
|
||||
sawUsage = true
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
c.CtxUsed = ev.Stats.ctxAfter()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
if local {
|
||||
turnPeak = max(turnPeak, ev.Stats.GenTokens)
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||
case ev.Ask != nil:
|
||||
// Question structurée (outil ask) : carte à boutons dans l'UI.
|
||||
asked = true
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||
@@ -620,7 +912,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.appendDelta(epoch, map[string]any{"content": ev.Content})
|
||||
}
|
||||
return true // génération détachée : on ne s'interrompt jamais sur un abonné
|
||||
})
|
||||
}, func() []Message { return c.drainQueued(epoch) })
|
||||
|
||||
// Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats)
|
||||
// PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle
|
||||
@@ -635,9 +927,19 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
|
||||
}
|
||||
// Rappel de loki resté sans réponse (stop, erreur) ou collé à un autre
|
||||
// user : retiré, sinon deux `user` d'affilée au tour suivant.
|
||||
c.Messages = dropStrayNudges(c.Messages)
|
||||
// Le dernier compte couvre tout ce qui vient d'être rangé : la dernière
|
||||
// requête portait le tour entier, sa génération en est la réponse.
|
||||
if sawUsage {
|
||||
c.ctxUsedLen = len(c.Messages)
|
||||
}
|
||||
c.genPeak = max(c.genPeak, turnPeak)
|
||||
}
|
||||
msgs = append([]Message(nil), c.Messages...)
|
||||
ctxUsed = c.CtxUsed
|
||||
ctxUsed = c.ctxNowLocked(msgs, local)
|
||||
peak = c.genPeak
|
||||
stale := c.epoch != epoch
|
||||
c.mu.Unlock()
|
||||
|
||||
@@ -649,6 +951,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = est
|
||||
c.ctxUsedLen = len(msgs)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(epoch, map[string]any{"ctx_used": est})
|
||||
@@ -659,10 +962,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// corrections, re-vérification — AVANT de rendre la main. Voir
|
||||
// code_verify.go. No-op hors mode code ou sans critères.
|
||||
if !stale && ctx.Err() == nil {
|
||||
c.codeVerifyLoop(ctx, caps, temperature, epoch)
|
||||
c.codeVerifyLoop(ctx, caps, temperature, epoch, asked)
|
||||
c.mu.Lock()
|
||||
msgs = append([]Message(nil), c.Messages...)
|
||||
ctxUsed = c.CtxUsed
|
||||
ctxUsed = c.ctxNowLocked(msgs, local)
|
||||
peak = c.genPeak
|
||||
stale = c.epoch != epoch
|
||||
c.mu.Unlock()
|
||||
}
|
||||
@@ -676,7 +980,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// retombe tout de suite et le tour suivant démarre avec de la marge.
|
||||
// Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé
|
||||
// (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop.
|
||||
if stale || ctx.Err() != nil || !compactWouldTrigger(msgs, ctxUsed) {
|
||||
if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) {
|
||||
return
|
||||
}
|
||||
// context.Background() et non ctx : le tour est terminé, son contexte peut
|
||||
@@ -684,6 +988,18 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps)
|
||||
}
|
||||
|
||||
// ctxNowLocked : contexte à juger pour compacter, c.mu tenu. llama-server
|
||||
// local : le dernier compte plus les messages arrivés depuis (ctxPending). Preset
|
||||
// externe : le dernier compte seul, comme avant — son CtxUsed garde encore le
|
||||
// raisonnement (ctxAfter n'y retire rien), qui couvre déjà ce complément ; l'y
|
||||
// ajouter le ferait compacter plus tôt qu'avant.
|
||||
func (c *Conversation) ctxNowLocked(msgs []Message, local bool) int {
|
||||
if !local {
|
||||
return c.CtxUsed
|
||||
}
|
||||
return ctxPending(c.CtxUsed, c.ctxUsedLen, msgs)
|
||||
}
|
||||
|
||||
// CompactNow force une compaction du contexte MAINTENANT, sans attendre le seuil
|
||||
// (bouton « compacter » de l'UI). Détaché comme la génération : émet la bannière
|
||||
// de progression, résume les anciens tours, remplace le torse et persiste. Les
|
||||
@@ -693,6 +1009,7 @@ func (c *Conversation) CompactNow() error {
|
||||
if !healthCheck() {
|
||||
return errModelLoading
|
||||
}
|
||||
local := !externalActive()
|
||||
c.mu.Lock()
|
||||
if c.Generating {
|
||||
c.mu.Unlock()
|
||||
@@ -702,7 +1019,7 @@ func (c *Conversation) CompactNow() error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.cancel = cancel
|
||||
msgs := append([]Message(nil), c.Messages...)
|
||||
lastReal := c.CtxUsed // dernier contexte réel mesuré, pour estimer le surcoût fixe
|
||||
lastReal := c.ctxNowLocked(msgs, local) // dernier contexte réel mesuré, pour estimer le surcoût fixe
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
|
||||
@@ -758,9 +1075,12 @@ func (c *Conversation) Reset() {
|
||||
c.Log = nil
|
||||
c.Seq = 0
|
||||
c.CtxUsed = 0
|
||||
c.ctxUsedLen, c.genPeak = 0, 0
|
||||
c.epoch++
|
||||
c.Generating = false
|
||||
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
|
||||
c.cancel = nil
|
||||
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
@@ -906,11 +1226,101 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribe diffuse les événements au client via emit : d'abord un REPLAY coalescé
|
||||
// historyTailDefault : échanges rejoués à l'ouverture d'une session (reset) pour
|
||||
// un client qui gère la pagination de l'historique.
|
||||
const historyTailDefault = 20
|
||||
|
||||
// historyCut calcule où couper le rejeu pour ne garder que les `tail` derniers
|
||||
// échanges (un échange commence à un événement `user`). Renvoie le Seq à partir
|
||||
// duquel rejouer (exclu) et le nombre d'échanges masqués ; (0, 0) = tout
|
||||
// rejouer. Repris d'AJEAN 0.15.7.
|
||||
func historyCut(log []LogEvent, tail int) (int, int) {
|
||||
if tail <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
seen := 0
|
||||
for i := len(log) - 1; i >= 0; i-- {
|
||||
if _, ok := log[i].Delta["user"]; !ok {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if seen == tail {
|
||||
hidden := 0
|
||||
for _, ev := range log[:i] {
|
||||
if _, ok := ev.Delta["user"]; ok {
|
||||
hidden++
|
||||
}
|
||||
}
|
||||
if hidden == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
return log[i].Seq - 1, hidden
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// historyHead : événements qui annoncent un historique tronqué. Les états que
|
||||
// les échanges masqués avaient posés et que le client garde (mode Chat/Code,
|
||||
// critères du mode Code) sont réémis tels qu'ils étaient à la coupe — sans eux,
|
||||
// une discussion en mode Code rouverte s'afficherait en mode Chat.
|
||||
func historyHead(log []LogEvent, cut, hidden int) []map[string]any {
|
||||
out := []map[string]any{{"history_more": hidden}}
|
||||
var mode, crit map[string]any
|
||||
for _, ev := range log {
|
||||
if ev.Seq > cut {
|
||||
break
|
||||
}
|
||||
if _, ok := ev.Delta["mode"]; ok {
|
||||
mode = ev.Delta
|
||||
}
|
||||
if _, ok := ev.Delta["criteria"]; ok {
|
||||
crit = ev.Delta
|
||||
}
|
||||
}
|
||||
for _, d := range []map[string]any{mode, crit} {
|
||||
if d != nil {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribe : abonnement sans pagination (rejeu complet).
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
|
||||
c.SubscribeTail(ctx, from, -1, "", emit)
|
||||
}
|
||||
|
||||
// SubscribeTail diffuse les événements au client via emit : d'abord un REPLAY coalescé
|
||||
// de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement.
|
||||
// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
|
||||
// continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
|
||||
func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, convID string, emit func(map[string]any) bool) {
|
||||
c.subscribeSink(ctx, from, tail, convID, tailSink{emit: emit})
|
||||
}
|
||||
|
||||
// tailSink : sortie d'un abonné. emit écrit et pousse aussitôt ; queue met en
|
||||
// tampon (en écrivant d'office un tampon plein) et flush pousse ce qui attend.
|
||||
// Sans queue/flush, tout passe par emit, un événement à la fois.
|
||||
type tailSink struct {
|
||||
emit func(map[string]any) bool
|
||||
queue func(map[string]any) bool
|
||||
flush func() bool
|
||||
}
|
||||
|
||||
// subscribeSink : SubscribeTail, en regroupant le direct. Au réveil, plusieurs
|
||||
// événements peuvent attendre (un jeton chacun) : une écriture et un flush par
|
||||
// événement multipliaient les appels système et les trames d'un client en
|
||||
// retard, ou d'un relais chiffré. Ils partent ensemble, dans l'ordre.
|
||||
func (c *Conversation) subscribeSink(ctx context.Context, from int, tail int, convID string, sink tailSink) {
|
||||
emit := sink.emit
|
||||
queue, flush := sink.queue, sink.flush
|
||||
if queue == nil {
|
||||
queue = emit
|
||||
}
|
||||
if flush == nil {
|
||||
flush = func() bool { return true }
|
||||
}
|
||||
// Réveille les attentes de cond quand la connexion se ferme.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
@@ -942,8 +1352,39 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
// la conversation ouverte apparaît tronquée, voire vide. En session normale, le
|
||||
// client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il
|
||||
// l'est, son curseur vient d'une autre session → on repart du début.
|
||||
//
|
||||
// ⚠️ Même famille, cas plus sournois (AJEAN 0.14.0) : un AUTRE appareil a
|
||||
// changé de discussion pendant que ce client était déconnecté, et son `from`
|
||||
// est PLUS BAS que le dernier Seq de la nouvelle. Le rejeu greffait alors la
|
||||
// fin de la nouvelle discussion sur le début de l'ancienne restée à l'écran —
|
||||
// deux fils fusionnés jusqu'au prochain rafraîchissement. Le client renvoie
|
||||
// l'id de la discussion qu'il affiche (convID) : s'il ne correspond plus, on
|
||||
// lui ordonne d'abord de vider l'écran (reset), puis on rejoue tout.
|
||||
curID := getStr(bkChat, ckActive)
|
||||
staleConv := from > 0 && convID != "" && convID != curID
|
||||
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
|
||||
from = 0
|
||||
staleConv = staleConv || convID != ""
|
||||
}
|
||||
if staleConv {
|
||||
from = 0
|
||||
if !emit(map[string]any{"reset": true, "id": curID}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Chargement initial d'un client paginé : on ne rejoue que la fin du fil,
|
||||
// précédée de {history_more: N}. Sur un long fil d'agent, tout rejouer
|
||||
// envoyait des Mo et bâtissait des milliers de bulles avant d'afficher quoi
|
||||
// que ce soit.
|
||||
if from == 0 && tail > 0 {
|
||||
if cut, hidden := historyCut(snapshot, tail); hidden > 0 {
|
||||
from = cut
|
||||
for _, ev := range historyHead(snapshot, cut, hidden) {
|
||||
if !emit(ev) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
last := from
|
||||
for _, ev := range coalesceReplay(snapshot, from) {
|
||||
@@ -957,7 +1398,7 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
last = s
|
||||
}
|
||||
}
|
||||
if !emit(map[string]any{"caught_up": true}) {
|
||||
if !emit(map[string]any{"caught_up": true, "id": curID}) {
|
||||
return
|
||||
}
|
||||
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les
|
||||
@@ -990,10 +1431,27 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
if c.epoch != epoch { // reset → on ordonne au client de nettoyer et on repart
|
||||
epoch = c.epoch
|
||||
last = 0
|
||||
// Session ouverte (fil complet qui suit) chez un client paginé : on
|
||||
// saute directement aux derniers échanges.
|
||||
var head []map[string]any
|
||||
if tail >= 0 {
|
||||
if cut, hidden := historyCut(c.Log, historyTailDefault); hidden > 0 {
|
||||
last = cut
|
||||
// ctx_used vit dans le journal des échanges masqués : on le
|
||||
// donne tel qu'il est maintenant, sinon la jauge resterait à 0.
|
||||
head = append(historyHead(c.Log, cut, hidden), map[string]any{"ctx_used": c.CtxUsed})
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if !emit(map[string]any{"reset": true}) {
|
||||
// id : la discussion désormais affichée (lue hors verrou — base).
|
||||
if !emit(map[string]any{"reset": true, "id": getStr(bkChat, ckActive)}) {
|
||||
return
|
||||
}
|
||||
for _, ev := range head {
|
||||
if !emit(ev) {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
continue
|
||||
}
|
||||
@@ -1022,10 +1480,13 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
// `lastEmitted` (avant mise à jour) sert de repère : si la compaction de
|
||||
// fin de tour vient de fusionner un bloc que le client suivait en direct,
|
||||
// l'événement fusionné arrive avec un Seq supérieur au sien → `replace`.
|
||||
if !emit(decorateEvent(ev, lastEmitted)) {
|
||||
if !queue(decorateEvent(ev, lastEmitted)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func migrateLegacyUploads(src string) {
|
||||
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
|
||||
// relie un fichier déposé à sa discussion.
|
||||
func convAttachNames(id string) []string {
|
||||
b := getBytes(bkChat, convKey(id))
|
||||
b := storeBytes(bkChat, convKey(id))
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
+83
-24
@@ -5,12 +5,18 @@ package loki
|
||||
// envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en
|
||||
// vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec
|
||||
// des garde-fous pour ne jamais transformer un gros remplacement en pavé.
|
||||
//
|
||||
// Les lignes envoyées sont bornées (diffMaxShown), mais les compteurs +N / -N
|
||||
// sont calculés AVANT la coupe et renvoyés à part : l'UI les comptait dans le
|
||||
// diff tronqué, d'où un « +500 » pendant l'écriture qui retombait à « +120 ».
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
diffMaxLines = 400 // au-delà, on compare sans détail (trop gros / trop lent)
|
||||
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
|
||||
diffMaxLines = 400 // au-delà (partie modifiée), on compare sans détail (LCS trop lente)
|
||||
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
|
||||
diffContext = 3 // lignes identiques gardées autour d'un changement
|
||||
diffOmitPrefix = "…"
|
||||
)
|
||||
|
||||
// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
|
||||
@@ -19,26 +25,69 @@ type DiffLine struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// lineDiff compare deux blocs de texte ligne à ligne. Le résultat garde les
|
||||
// lignes communes comme contexte : c'est ce qui rend le changement lisible
|
||||
// quand l'IA ne touche qu'un mot au milieu d'un paragraphe.
|
||||
func lineDiff(oldText, newText string) []DiffLine {
|
||||
// lineDiff compare deux blocs de texte ligne à ligne et renvoie les lignes à
|
||||
// afficher (bornées) avec le nombre RÉEL de lignes ajoutées et retirées.
|
||||
//
|
||||
// Les lignes communes en tête et en queue sont écartées avant la LCS : un
|
||||
// changement d'une ligne dans un gros bloc reste un +1 -1 lisible (au lieu d'un
|
||||
// « tout retiré, tout ajouté ») et le changement n'est pas repoussé hors des
|
||||
// lignes affichées par des centaines de lignes de contexte.
|
||||
func lineDiff(oldText, newText string) (lines []DiffLine, added, removed int) {
|
||||
a := splitLines(oldText)
|
||||
b := splitLines(newText)
|
||||
// Blocs énormes : on ne calcule pas la LCS (coût quadratique), on montre
|
||||
// simplement l'ancien en retrait et le nouveau en ajout.
|
||||
if len(a) > diffMaxLines || len(b) > diffMaxLines {
|
||||
out := make([]DiffLine, 0, len(a)+len(b))
|
||||
for _, l := range a {
|
||||
out = append(out, DiffLine{Op: "-", Text: l})
|
||||
pre := 0
|
||||
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
|
||||
pre++
|
||||
}
|
||||
suf := 0
|
||||
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
|
||||
suf++
|
||||
}
|
||||
ma, mb := a[pre:len(a)-suf], b[pre:len(b)-suf]
|
||||
|
||||
var mid []DiffLine
|
||||
if len(ma) > diffMaxLines || len(mb) > diffMaxLines {
|
||||
// Partie modifiée énorme : pas de LCS (coût quadratique), l'ancien en
|
||||
// retrait puis le nouveau en ajout.
|
||||
mid = make([]DiffLine, 0, len(ma)+len(mb))
|
||||
for _, l := range ma {
|
||||
mid = append(mid, DiffLine{Op: "-", Text: l})
|
||||
}
|
||||
for _, l := range b {
|
||||
out = append(out, DiffLine{Op: "+", Text: l})
|
||||
for _, l := range mb {
|
||||
mid = append(mid, DiffLine{Op: "+", Text: l})
|
||||
}
|
||||
} else {
|
||||
mid = lcsDiff(ma, mb)
|
||||
}
|
||||
for _, d := range mid {
|
||||
switch d.Op {
|
||||
case "+":
|
||||
added++
|
||||
case "-":
|
||||
removed++
|
||||
}
|
||||
return capLines(out)
|
||||
}
|
||||
|
||||
// LCS : table des longueurs, puis remontée.
|
||||
// Contexte autour du changement : quelques lignes, le reste résumé.
|
||||
var out []DiffLine
|
||||
if pre > diffContext {
|
||||
out = append(out, DiffLine{Op: " ", Text: omitted(pre - diffContext)})
|
||||
}
|
||||
for _, l := range a[max(0, pre-diffContext):pre] {
|
||||
out = append(out, DiffLine{Op: " ", Text: l})
|
||||
}
|
||||
out = append(out, mid...)
|
||||
for _, l := range a[len(a)-suf : len(a)-suf+min(suf, diffContext)] {
|
||||
out = append(out, DiffLine{Op: " ", Text: l})
|
||||
}
|
||||
if suf > diffContext {
|
||||
out = append(out, DiffLine{Op: " ", Text: omitted(suf - diffContext)})
|
||||
}
|
||||
return capLines(out), added, removed
|
||||
}
|
||||
|
||||
// lcsDiff : diff exact par plus longue sous-séquence commune.
|
||||
func lcsDiff(a, b []string) []DiffLine {
|
||||
n, m := len(a), len(b)
|
||||
lcs := make([][]int, n+1)
|
||||
for i := range lcs {
|
||||
@@ -77,27 +126,37 @@ func lineDiff(oldText, newText string) []DiffLine {
|
||||
for ; j < m; j++ {
|
||||
out = append(out, DiffLine{Op: "+", Text: b[j]})
|
||||
}
|
||||
return capLines(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// addedDiff présente un contenu entièrement nouveau (création d'une page).
|
||||
func addedDiff(text string) []DiffLine {
|
||||
lines := splitLines(text)
|
||||
out := make([]DiffLine, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
// addedDiff présente un contenu entièrement nouveau (création d'une page ou
|
||||
// d'un fichier) : toutes ses lignes en ajout.
|
||||
func addedDiff(text string) (lines []DiffLine, added int) {
|
||||
ls := splitLines(text)
|
||||
out := make([]DiffLine, 0, len(ls))
|
||||
for _, l := range ls {
|
||||
out = append(out, DiffLine{Op: "+", Text: l})
|
||||
}
|
||||
return capLines(out)
|
||||
return capLines(out), len(ls)
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
|
||||
s = strings.TrimSuffix(s, "\n")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
func omitted(n int) string {
|
||||
if n == 1 {
|
||||
return diffOmitPrefix + "(1 ligne identique)"
|
||||
}
|
||||
return diffOmitPrefix + "(" + itoa(n) + " lignes identiques)"
|
||||
}
|
||||
|
||||
// capLines borne la taille envoyée à l'UI et signale ce qui a été coupé.
|
||||
func capLines(d []DiffLine) []DiffLine {
|
||||
if len(d) <= diffMaxShown {
|
||||
@@ -105,7 +164,7 @@ func capLines(d []DiffLine) []DiffLine {
|
||||
}
|
||||
cut := len(d) - diffMaxShown
|
||||
out := append([]DiffLine{}, d[:diffMaxShown]...)
|
||||
return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"})
|
||||
return append(out, DiffLine{Op: " ", Text: diffOmitPrefix + "(" + itoa(cut) + " lignes de plus)"})
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func numbered(n int, suffix string) string {
|
||||
var b strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
b.WriteString("ligne " + itoa(i) + suffix + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Un fichier de 500 lignes : +500, même si l'UI n'en reçoit que 120.
|
||||
func TestAddedDiffCountsBeyondCap(t *testing.T) {
|
||||
lines, added := addedDiff(numbered(500, ""))
|
||||
if added != 500 {
|
||||
t.Fatalf("added = %d, attendu 500", added)
|
||||
}
|
||||
if len(lines) > diffMaxShown+1 {
|
||||
t.Fatalf("%d lignes envoyées, plafond %d", len(lines), diffMaxShown)
|
||||
}
|
||||
}
|
||||
|
||||
// Le saut de ligne final ne compte pas pour une ligne.
|
||||
func TestAddedDiffTrailingNewline(t *testing.T) {
|
||||
if _, n := addedDiff("a\nb\n"); n != 2 {
|
||||
t.Fatalf("n = %d, attendu 2", n)
|
||||
}
|
||||
if _, n := addedDiff("a\nb"); n != 2 {
|
||||
t.Fatalf("n = %d, attendu 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Une ligne changée au milieu d'un gros bloc : +1 -1, et le changement est
|
||||
// bien dans les lignes affichées (pas noyé sous le contexte).
|
||||
func TestLineDiffSmallChangeInBigBlock(t *testing.T) {
|
||||
old := numbered(800, "")
|
||||
neu := strings.Replace(old, "ligne 600\n", "ligne 600 modifiée\n", 1)
|
||||
lines, add, del := lineDiff(old, neu)
|
||||
if add != 1 || del != 1 {
|
||||
t.Fatalf("+%d -%d, attendu +1 -1", add, del)
|
||||
}
|
||||
var sawAdd, sawDel bool
|
||||
for _, l := range lines {
|
||||
sawAdd = sawAdd || (l.Op == "+" && l.Text == "ligne 600 modifiée")
|
||||
sawDel = sawDel || (l.Op == "-" && l.Text == "ligne 600")
|
||||
}
|
||||
if !sawAdd || !sawDel {
|
||||
t.Fatalf("le changement n'est pas dans le diff affiché : %v", lines)
|
||||
}
|
||||
if len(lines) > 2*diffContext+4 {
|
||||
t.Fatalf("trop de contexte : %d lignes", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineDiffCountsAreExact(t *testing.T) {
|
||||
_, add, del := lineDiff("a\nb\nc\n", "a\nx\ny\nc\n")
|
||||
if add != 2 || del != 1 {
|
||||
t.Fatalf("+%d -%d, attendu +2 -1", add, del)
|
||||
}
|
||||
_, add, del = lineDiff(numbered(300, ""), numbered(300, " bis"))
|
||||
if add != 300 || del != 300 {
|
||||
t.Fatalf("+%d -%d, attendu +300 -300", add, del)
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,8 @@ func filterLog(log []LogEvent, o exportOpts) []LogEvent {
|
||||
func lastTurnsMessages(msgs []Message, n int) []Message {
|
||||
seen := 0
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role != "user" {
|
||||
// Un rappel de loki n'ouvre pas un échange.
|
||||
if msgs[i].Role != "user" || isLokiInjected(msgs[i]) {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// historyCut ne garde que les N derniers échanges (un échange commence à `user`).
|
||||
func TestHistoryCut(t *testing.T) {
|
||||
var log []LogEvent
|
||||
seq := 0
|
||||
add := func(d map[string]any) { seq++; log = append(log, LogEvent{Seq: seq, Delta: d}) }
|
||||
for i := 0; i < 5; i++ {
|
||||
add(map[string]any{"user": "q"})
|
||||
add(map[string]any{"content": "r"})
|
||||
add(map[string]any{"turn_done": true})
|
||||
}
|
||||
// 5 échanges, seq 1..15 ; les 2 derniers commencent aux seq 10 et 13.
|
||||
if cut, hidden := historyCut(log, 2); cut != 9 || hidden != 3 {
|
||||
t.Fatalf("tail=2 : cut=%d hidden=%d, attendu 9 et 3", cut, hidden)
|
||||
}
|
||||
if cut, hidden := historyCut(log, 5); cut != 0 || hidden != 0 {
|
||||
t.Fatalf("tail=5 (tout tient) : cut=%d hidden=%d, attendu 0 et 0", cut, hidden)
|
||||
}
|
||||
if cut, hidden := historyCut(log, 0); cut != 0 || hidden != 0 {
|
||||
t.Fatalf("tail=0 : pas de coupure attendue (cut=%d hidden=%d)", cut, hidden)
|
||||
}
|
||||
// Les événements rejoués après la coupure commencent bien au 4e échange.
|
||||
evs := coalesceReplay(log, 9)
|
||||
if u, _ := evs[0]["user"].(string); u != "q" || evs[0]["seq"] != 10 {
|
||||
t.Fatalf("premier événement rejoué inattendu : %v", evs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// La coupe réémet le mode (Chat/Code) posé dans la partie masquée.
|
||||
func TestHistoryHeadCarriesMode(t *testing.T) {
|
||||
log := []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"mode": "code"}},
|
||||
{Seq: 2, Delta: map[string]any{"user": "q1"}},
|
||||
{Seq: 3, Delta: map[string]any{"user": "q2"}},
|
||||
}
|
||||
head := historyHead(log, 2, 1)
|
||||
if len(head) != 2 || head[0]["history_more"] != 1 || head[1]["mode"] != "code" {
|
||||
t.Fatalf("en-tête inattendu : %v", head)
|
||||
}
|
||||
}
|
||||
|
||||
// Un client paginé ne reçoit au chargement que les derniers échanges, précédés
|
||||
// de {history_more} ; un ancien client (tail < 0) reçoit tout.
|
||||
func TestSubscribeTailPaginates(t *testing.T) {
|
||||
c := newTestConv()
|
||||
for i := 0; i < 5; i++ {
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "q"})
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "r"})
|
||||
}
|
||||
collect := func(tail int) (more any, users int) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c.SubscribeTail(ctx, 0, tail, "", func(m map[string]any) bool {
|
||||
if v, ok := m["history_more"]; ok {
|
||||
more = v
|
||||
}
|
||||
if _, ok := m["user"]; ok {
|
||||
users++
|
||||
}
|
||||
if _, ok := m["caught_up"]; ok {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return
|
||||
}
|
||||
if more, users := collect(2); more != 3 || users != 2 {
|
||||
t.Fatalf("tail=2 : history_more=%v, %d échanges rejoués (attendu 3 et 2)", more, users)
|
||||
}
|
||||
if more, users := collect(-1); more != nil || users != 5 {
|
||||
t.Fatalf("ancien client : history_more=%v, %d échanges (attendu rien et 5)", more, users)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chat_images.go — images d'une conversation stockées PAR RÉFÉRENCE.
|
||||
//
|
||||
// Une image vue par le modèle (pièce jointe, see_image, capture du navigateur) est
|
||||
// une partie `image_url` en base64 dans un message. Gardée telle quelle dans
|
||||
// l'historique, elle y pesait des Mo (une conversation de 14 messages : 36 Mo), et
|
||||
// l'historique entier est réécrit à chaque fin de tour et relu à chaque ouverture.
|
||||
//
|
||||
// On la range donc UNE fois sur disque, sous LOKI_HOME/chatimg/<empreinte>.<ext>
|
||||
// (hors du dossier de travail, que l'IA peut vider), et l'historique n'en garde
|
||||
// que l'adresse « loki-img:<nom> ». Juste avant l'envoi au modèle,
|
||||
// expandImageRefs remet les octets exacts (cache mémoire) : pour le modèle, rien
|
||||
// ne change. Mêmes octets, même empreinte → aucune copie en double.
|
||||
|
||||
const imgRefScheme = "loki-img:"
|
||||
|
||||
func chatImgDir() string { return filepath.Join(LokiHome(), "chatimg") }
|
||||
|
||||
var imgExtByMime = map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"}
|
||||
|
||||
// storeChatImage range les octets d'une image et renvoie sa référence.
|
||||
func storeChatImage(b []byte, mime string) (string, error) {
|
||||
ext := imgExtByMime[mime]
|
||||
if ext == "" {
|
||||
ext = ".img"
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
name := hex.EncodeToString(sum[:16]) + ext
|
||||
dir := chatImgDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
// Chiffrée comme le fil quand la mémoire l'est (même enveloppe que les
|
||||
// pages). Verrouillée : refus, l'image reste en base64 dans le message.
|
||||
enc, err := encodeMemContent(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return imgRefScheme + name, nil
|
||||
}
|
||||
|
||||
// Cache des data-URL reconstituées : un tour d'agent renvoie tout l'historique à
|
||||
// CHAQUE étape, on ne relit pas le disque à chaque fois. Borné en taille.
|
||||
var (
|
||||
imgCacheMu sync.Mutex
|
||||
imgCache = map[string]string{}
|
||||
imgCacheOrder []string
|
||||
imgCacheBytes int
|
||||
)
|
||||
|
||||
const imgCacheMax = 64 << 20
|
||||
|
||||
func chatImageDataURL(ref string) (string, bool) {
|
||||
name := strings.TrimPrefix(ref, imgRefScheme)
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return "", false
|
||||
}
|
||||
imgCacheMu.Lock()
|
||||
if u, ok := imgCache[name]; ok {
|
||||
imgCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
imgCacheMu.Unlock()
|
||||
raw, err := os.ReadFile(filepath.Join(chatImgDir(), name))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
b, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
mime := "image/jpeg"
|
||||
for m, e := range imgExtByMime {
|
||||
if strings.HasSuffix(name, e) {
|
||||
mime = m
|
||||
}
|
||||
}
|
||||
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
|
||||
imgCacheMu.Lock()
|
||||
if _, ok := imgCache[name]; !ok {
|
||||
imgCache[name] = u
|
||||
imgCacheOrder = append(imgCacheOrder, name)
|
||||
imgCacheBytes += len(u)
|
||||
for imgCacheBytes > imgCacheMax && len(imgCacheOrder) > 1 {
|
||||
old := imgCacheOrder[0]
|
||||
imgCacheOrder = imgCacheOrder[1:]
|
||||
imgCacheBytes -= len(imgCache[old])
|
||||
delete(imgCache, old)
|
||||
}
|
||||
}
|
||||
imgCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
|
||||
// chatImgKeep : âge minimal d'une image avant qu'on puisse l'effacer.
|
||||
const chatImgKeep = 24 * time.Hour
|
||||
|
||||
// pruneChatImages efface les images de plus de chatImgKeep. Appelée au
|
||||
// démarrage et à chaque bascule de discussion, c'est-à-dire quand le fil
|
||||
// vivant vient d'être rechargé — et stripImageParts retire les images d'un fil
|
||||
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
|
||||
// fil resté ouvert longtemps sans bascule.
|
||||
func pruneChatImages() {
|
||||
dir := chatImgDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-chatImgKeep)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// contentParts renvoie les parties d'un contenu multimodal, sous l'une ou l'autre
|
||||
// forme (fraîche []map, ou relue du JSON []any).
|
||||
func contentParts(content any) []map[string]any {
|
||||
switch v := content.(type) {
|
||||
case []map[string]any:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func partImageURL(p map[string]any) (map[string]any, string) {
|
||||
if p["type"] != "image_url" {
|
||||
return nil, ""
|
||||
}
|
||||
iu, _ := p["image_url"].(map[string]any)
|
||||
if iu == nil {
|
||||
return nil, ""
|
||||
}
|
||||
u, _ := iu["url"].(string)
|
||||
return iu, u
|
||||
}
|
||||
|
||||
// refImagesInMessages remplace EN PLACE les images en base64 des messages par des
|
||||
// références (voir plus haut). Renvoie true si quelque chose a changé. Une image
|
||||
// qui ne peut pas être rangée (disque plein…) reste en base64 : rien ne se perd.
|
||||
//
|
||||
// ⚠️ Écart avec l'amont : les parties modifiées sont RECOPIÉES, jamais mutées.
|
||||
// persist peut tourner pendant qu'un runChat (compactage en vol, bascule de
|
||||
// projet) sérialise encore ces mêmes maps : une écriture en place y déclenchait
|
||||
// un « concurrent map read and map write », fatal pour le process.
|
||||
func refImagesInMessages(msgs []Message) bool {
|
||||
changed := false
|
||||
for mi, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
var np []map[string]any
|
||||
for pi, p := range parts {
|
||||
_, u := partImageURL(p)
|
||||
if !strings.HasPrefix(u, "data:image/") {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(u, ";base64,")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(u[i+8:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ref, err := storeChatImage(b, u[5:i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if np == nil {
|
||||
np = append([]map[string]any(nil), parts...)
|
||||
}
|
||||
np[pi] = map[string]any{"type": "image_url", "image_url": map[string]any{"url": ref}}
|
||||
}
|
||||
if np != nil {
|
||||
msgs[mi].Content = np
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hasInlineImages : au moins une image encore en base64 dans ces messages ?
|
||||
func hasInlineImages(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
for _, p := range contentParts(m.Content) {
|
||||
if _, u := partImageURL(p); strings.HasPrefix(u, "data:image/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expandImageRefs renvoie une COPIE des messages prête pour le modèle : chaque
|
||||
// référence d'image redevient sa data-URL. Les messages sans référence sont
|
||||
// partagés tels quels. Une image introuvable (dossier supprimé à la main) devient
|
||||
// une mention textuelle plutôt qu'une requête qui échouerait.
|
||||
func expandImageRefs(msgs []Message) []Message {
|
||||
out := msgs
|
||||
copied := false
|
||||
for i, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
has := false
|
||||
for _, p := range parts {
|
||||
if _, u := partImageURL(p); strings.HasPrefix(u, imgRefScheme) {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if !copied {
|
||||
out = append([]Message(nil), msgs...)
|
||||
copied = true
|
||||
}
|
||||
np := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
_, u := partImageURL(p)
|
||||
if !strings.HasPrefix(u, imgRefScheme) {
|
||||
np = append(np, p)
|
||||
continue
|
||||
}
|
||||
if d, ok := chatImageDataURL(u); ok {
|
||||
np = append(np, map[string]any{"type": "image_url", "image_url": map[string]any{"url": d}})
|
||||
} else {
|
||||
np = append(np, map[string]any{"type": "text", "text": "[image indisponible : fichier supprimé]"})
|
||||
}
|
||||
}
|
||||
m.Content = np
|
||||
out[i] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Une image en base64 part sur disque et revient identique à l'envoi au modèle ;
|
||||
// l'historique ne garde qu'une référence de quelques octets.
|
||||
func TestImageRefsRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
raw := []byte(strings.Repeat("\x89PNG-fake-image-bytes", 5000))
|
||||
url := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": url}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
// Relu depuis le JSON (forme []any), comme après un redémarrage.
|
||||
var reread []Message
|
||||
b, _ := json.Marshal(msgs)
|
||||
_ = json.Unmarshal(b, &reread)
|
||||
|
||||
for _, set := range [][]Message{msgs, reread} {
|
||||
if !hasInlineImages(set) || !refImagesInMessages(set) || hasInlineImages(set) {
|
||||
t.Fatal("l'image aurait dû être rangée par référence")
|
||||
}
|
||||
js, _ := json.Marshal(set)
|
||||
if len(js) > 500 || !strings.Contains(string(js), imgRefScheme) {
|
||||
t.Fatalf("historique encore lourd : %d octets", len(js))
|
||||
}
|
||||
out := expandImageRefs(set)
|
||||
_, got := partImageURL(contentParts(out[0].Content)[1])
|
||||
if got != url {
|
||||
t.Fatal("l'image renvoyée au modèle diffère de l'originale")
|
||||
}
|
||||
// L'historique lui-même n'est pas modifié par l'expansion.
|
||||
if _, u := partImageURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, imgRefScheme) {
|
||||
t.Fatal("expandImageRefs ne doit pas toucher l'historique")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mémoire chiffrée : l'image rangée est chiffrée sur disque et revient intacte ;
|
||||
// verrouillée, elle n'est pas écrite (elle reste en base64 dans le message).
|
||||
func TestImageRefsChiffrees(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
|
||||
t.Fatalf("EnableMemEncryption: %v", err)
|
||||
}
|
||||
raw := []byte(strings.Repeat("\xff\xd8JPEG-fake", 3000))
|
||||
ref, err := storeChatImage(raw, "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("storeChatImage: %v", err)
|
||||
}
|
||||
disk, _ := os.ReadFile(filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme)))
|
||||
if !looksEncrypted(disk) {
|
||||
t.Fatal("image en clair sur disque alors que la mémoire est chiffrée")
|
||||
}
|
||||
u, ok := chatImageDataURL(ref)
|
||||
if !ok || u != "data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(raw) {
|
||||
t.Fatal("image chiffrée non restituée à l'identique")
|
||||
}
|
||||
clearMemDEK()
|
||||
if _, err := storeChatImage([]byte("autre image"), "image/png"); err == nil {
|
||||
t.Fatal("mémoire verrouillée : l'image n'aurait pas dû être écrite")
|
||||
}
|
||||
}
|
||||
|
||||
// Les images de plus de chatImgKeep sont effacées, les récentes restent.
|
||||
func TestPruneChatImages(t *testing.T) {
|
||||
testHome(t)
|
||||
vieille, _ := storeChatImage([]byte("vieille"), "image/png")
|
||||
recente, _ := storeChatImage([]byte("récente"), "image/png")
|
||||
pv := filepath.Join(chatImgDir(), strings.TrimPrefix(vieille, imgRefScheme))
|
||||
old := time.Now().Add(-chatImgKeep - time.Hour)
|
||||
if err := os.Chtimes(pv, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pruneChatImages()
|
||||
if _, err := os.Stat(pv); !os.IsNotExist(err) {
|
||||
t.Fatal("la vieille image aurait dû être effacée")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(chatImgDir(), strings.TrimPrefix(recente, imgRefScheme))); err != nil {
|
||||
t.Fatal("la récente image a disparu")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func webGrepTool() Tool {
|
||||
// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
|
||||
|
||||
// webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme
|
||||
// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) pour MCP. Sans ce
|
||||
// toolMaxOutput (8000) pour le shell et mcpOutputCap pour MCP. Sans ce
|
||||
// plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000
|
||||
// caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui
|
||||
// déclenchait des compactages en cascade au milieu du raisonnement.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// chat_mem_pinned.go — RAPPEL des pages mémoire lues au compactage.
|
||||
//
|
||||
// Problème corrigé : quand le contexte est compacté, le torse (dont les résultats
|
||||
// de mem_read) est résumé. Une page mémoire qui portait, par exemple, TOUTES LES
|
||||
// RÈGLES à respecter pour une tâche était donc réduite à quelques mots dans le
|
||||
// résumé — et le modèle, après compactage, « oubliait » les règles et partait en
|
||||
// vrille.
|
||||
//
|
||||
// Choix retenu (le plus léger pour le contexte) : on ne garde RIEN verbatim. On
|
||||
// injecte simplement, en tête de la conversation compactée, un petit rappel qui
|
||||
// LISTE les pages que le modèle a lues (mem_read) et l'invite à en relire une si
|
||||
// elle est pertinente, pour être sûr d'avoir toutes les infos avant de répondre. Le
|
||||
// rappel ne pèse que les NOMS des pages (borné), le contenu complet reste à un
|
||||
// mem_read de distance. Le comportement de compactage est par ailleurs inchangé.
|
||||
//
|
||||
// Le rappel ACCUMULE les noms à travers les compactages successifs : le rappel du
|
||||
// compactage précédent est relu comme source, donc une page lue tôt (dont le
|
||||
// mem_read d'origine a depuis été résumé) reste listée.
|
||||
|
||||
// memReminderPrefix ouvre le message de rappel. Sert aussi à le reconnaître (pour le
|
||||
// relire comme source au compactage suivant, et pour le retirer avant reconstruction).
|
||||
const memReminderPrefix = "[MEMORY PAGES REMINDER]"
|
||||
|
||||
// memReminderMaxNames borne le nombre de noms listés : au-delà, une conversation qui
|
||||
// lit énormément de pages ne ferait pas enfler le rappel. On garde les plus RÉCENTES
|
||||
// (les plus susceptibles de concerner la tâche en cours).
|
||||
const memReminderMaxNames = 24
|
||||
|
||||
// collectReadPageNames rassemble les noms des pages mémoire LUES au fil de la
|
||||
// conversation, dans l'ordre (première lecture d'abord). Deux sources, toutes deux
|
||||
// balayées pour survivre aux compactages successifs :
|
||||
// - les résultats de mem_read encore présents (résultat `tool` relié à sa page via
|
||||
// l'argument `file` du tool_call correspondant), en ignorant les lectures ratées ;
|
||||
// - le rappel injecté à un compactage PRÉCÉDENT (une fois le mem_read d'origine
|
||||
// résumé, c'est la seule trace restante des noms).
|
||||
func collectReadPageNames(msgs []Message) []string {
|
||||
seen := map[string]bool{}
|
||||
var order []string
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
order = append(order, name)
|
||||
}
|
||||
// tool_call_id → nom de page pour les appels mem_read (le résultat `tool` ne
|
||||
// porte que l'id ; le nom vit dans l'argument `file` de l'appel de l'assistant).
|
||||
readCall := map[string]string{}
|
||||
for _, m := range msgs {
|
||||
for _, tc := range m.ToolCalls {
|
||||
if tc.Function.Name != "mem_read" || tc.ID == "" {
|
||||
continue
|
||||
}
|
||||
var a map[string]any
|
||||
if json.Unmarshal([]byte(tc.Function.Arguments), &a) == nil {
|
||||
if f, _ := a["file"].(string); f != "" {
|
||||
readCall[tc.ID] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case "system":
|
||||
for _, n := range parseReminderNames(msgText(m)) {
|
||||
add(n)
|
||||
}
|
||||
case "tool":
|
||||
name, ok := readCall[m.ToolCallID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(msgText(m)), "[erreur]") {
|
||||
continue // lecture ratée : rien à rappeler
|
||||
}
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// parseReminderNames relit la liste de noms d'un message de rappel produit par
|
||||
// buildReminderMessage. Renvoie nil si ce n'est pas un rappel.
|
||||
func parseReminderNames(text string) []string {
|
||||
if !strings.HasPrefix(text, memReminderPrefix) {
|
||||
return nil
|
||||
}
|
||||
// Format : 1re ligne `[MEMORY PAGES REMINDER] … read earlier: a.md, b.md, c.md`
|
||||
// (les noms après le dernier `: `, sans ponctuation finale) ; la consigne de
|
||||
// relecture est sur la ou les lignes SUIVANTES, donc hors de la liste.
|
||||
line := text
|
||||
if i := strings.IndexByte(line, '\n'); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
i := strings.LastIndex(line, ": ")
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
list := line[i+2:]
|
||||
var out []string
|
||||
for _, part := range strings.Split(list, ",") {
|
||||
if n := strings.TrimSpace(part); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildReminderMessage construit le message `system` de rappel. Renvoie ok=false si
|
||||
// aucune page n'a été lue. Borné à memReminderMaxNames (on garde les plus récentes).
|
||||
func buildReminderMessage(names []string) (Message, bool) {
|
||||
if len(names) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
if len(names) > memReminderMaxNames {
|
||||
names = names[len(names)-memReminderMaxNames:] // les plus récentes
|
||||
}
|
||||
content := fmt.Sprintf("%s Memory pages you read earlier: %s\nTheir full content is no longer inline after context compaction — if any of them is relevant to what you're doing, mem_read it again so you have all the rules and info you need before answering.",
|
||||
memReminderPrefix, strings.Join(names, ", "))
|
||||
return Message{Role: "system", Content: content}, true
|
||||
}
|
||||
|
||||
// stripReminder retire le(s) message(s) de rappel d'une séquence (avant reconstruction).
|
||||
func stripReminder(msgs []Message) []Message {
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// remindReadMemPages reconstruit, à partir de l'historique AVANT compactage (source),
|
||||
// le rappel des pages mémoire lues, et l'injecte en tête de la séquence COMPACTÉE. On
|
||||
// retire d'abord un éventuel rappel hérité (porté par compacted depuis la tête
|
||||
// protégée) pour repartir d'un rappel propre et à jour.
|
||||
//
|
||||
// Appelé uniquement en mode agent (les outils mem_* existent) et hors mémoire coupée.
|
||||
// Sans page lue, no-op.
|
||||
func remindReadMemPages(compacted, source []Message) []Message {
|
||||
if memMode() == MemOff {
|
||||
return compacted
|
||||
}
|
||||
names := collectReadPageNames(source)
|
||||
compacted = stripReminder(compacted)
|
||||
if m, ok := buildReminderMessage(names); ok {
|
||||
return append([]Message{m}, compacted...)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Une page mémoire lue (mem_read) doit être retrouvée par son nom (via l'argument
|
||||
// `file` du tool_call), puis listée dans un rappel relisable.
|
||||
func TestReminderRoundTrip(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: "applique les règles de la page"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{
|
||||
ID: "call_1", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"regles-tache.md"}`},
|
||||
}}},
|
||||
{Role: "tool", ToolCallID: "call_1", Content: "RÈGLE 1 : toujours vouvoyer."},
|
||||
}
|
||||
names := collectReadPageNames(msgs)
|
||||
if len(names) != 1 || names[0] != "regles-tache.md" {
|
||||
t.Fatalf("nom de page mal collecté : %v", names)
|
||||
}
|
||||
m, ok := buildReminderMessage(names)
|
||||
if !ok {
|
||||
t.Fatalf("un rappel aurait dû être construit")
|
||||
}
|
||||
txt, _ := m.Content.(string)
|
||||
if !strings.Contains(txt, "regles-tache.md") || !strings.Contains(txt, "mem_read") {
|
||||
t.Fatalf("le rappel ne cite pas la page ou n'invite pas à relire : %q", txt)
|
||||
}
|
||||
// Le rappel doit se relire (noms) pour survivre au compactage suivant.
|
||||
back := parseReminderNames(txt)
|
||||
if len(back) != 1 || back[0] != "regles-tache.md" {
|
||||
t.Fatalf("aller-retour du rappel cassé : %v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// Le rappel doit ACCUMULER : un nom listé à un compactage précédent (dont le mem_read
|
||||
// d'origine a été résumé/effacé) est retrouvé via le rappel système, même sans le
|
||||
// résultat d'outil correspondant.
|
||||
func TestReminderAccumulatesAcrossCompactions(t *testing.T) {
|
||||
old, _ := buildReminderMessage([]string{"regles.md"})
|
||||
msgs := []Message{
|
||||
old, // rappel hérité (system)
|
||||
{Role: "user", Content: "[CONTEXT COMPACTED] résumé…"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{
|
||||
ID: "c2", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"procedure.md"}`},
|
||||
}}},
|
||||
{Role: "tool", ToolCallID: "c2", Content: "procédure détaillée"},
|
||||
}
|
||||
names := collectReadPageNames(msgs)
|
||||
joined := strings.Join(names, ",")
|
||||
if !strings.Contains(joined, "regles.md") {
|
||||
t.Fatalf("la page héritée a été perdue : %v", names)
|
||||
}
|
||||
if !strings.Contains(joined, "procedure.md") {
|
||||
t.Fatalf("la nouvelle page lue n'a pas été captée : %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une même page lue plusieurs fois n'est listée qu'une fois.
|
||||
func TestReminderDedupes(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "a", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
|
||||
{Role: "tool", ToolCallID: "a", Content: "v1"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "b", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
|
||||
{Role: "tool", ToolCallID: "b", Content: "v2"},
|
||||
}
|
||||
if names := collectReadPageNames(msgs); len(names) != 1 || names[0] != "p.md" {
|
||||
t.Fatalf("attendu une seule occurrence de p.md, obtenu %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une lecture ratée ([erreur] …) ne doit pas être rappelée.
|
||||
func TestReminderSkipsErrors(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "e", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"absente.md"}`}}}},
|
||||
{Role: "tool", ToolCallID: "e", Content: "[erreur] page introuvable"},
|
||||
}
|
||||
if names := collectReadPageNames(msgs); len(names) != 0 {
|
||||
t.Fatalf("une lecture ratée ne doit pas être rappelée, obtenu %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// La liste de noms est bornée : au-delà du plafond, on garde les plus récentes.
|
||||
func TestReminderCapsNames(t *testing.T) {
|
||||
var names []string
|
||||
for i := 0; i < memReminderMaxNames+10; i++ {
|
||||
names = append(names, "page-"+string(rune('a'+i%26))+string(rune('0'+i/26))+".md")
|
||||
}
|
||||
m, ok := buildReminderMessage(names)
|
||||
if !ok {
|
||||
t.Fatalf("rappel attendu")
|
||||
}
|
||||
got := parseReminderNames(m.Content.(string))
|
||||
if len(got) != memReminderMaxNames {
|
||||
t.Fatalf("attendu %d noms max, obtenu %d", memReminderMaxNames, len(got))
|
||||
}
|
||||
// La toute dernière (plus récente) doit être conservée.
|
||||
if got[len(got)-1] != names[len(names)-1] {
|
||||
t.Fatalf("la page la plus récente doit être gardée : %q vs %q", got[len(got)-1], names[len(names)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// stripReminder retire le rappel sans toucher au reste.
|
||||
func TestStripReminder(t *testing.T) {
|
||||
m, _ := buildReminderMessage([]string{"p.md"})
|
||||
msgs := []Message{
|
||||
m,
|
||||
{Role: "system", Content: "Project context — autre chose"},
|
||||
{Role: "user", Content: "salut"},
|
||||
}
|
||||
out := stripReminder(msgs)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("attendu 2 messages après strip, obtenu %d", len(out))
|
||||
}
|
||||
for _, mm := range out {
|
||||
if s, _ := mm.Content.(string); strings.HasPrefix(s, memReminderPrefix) {
|
||||
t.Fatalf("le rappel a survécu au strip")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,15 @@ func MemList() []MemPage {
|
||||
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
continue
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name()))
|
||||
out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))})
|
||||
// Page chiffrée + mémoire verrouillée : on la LISTE quand même (elle
|
||||
// existe, la cacher ferait croire à une perte) avec un titre qui dit
|
||||
// pourquoi son contenu n'apparaît pas.
|
||||
txt, ok := memPageText(e.Name())
|
||||
title := titleOf(txt)
|
||||
if !ok {
|
||||
title = "🔒 (chiffré — mémoire verrouillée)"
|
||||
}
|
||||
out = append(out, MemPage{Name: e.Name(), Title: title})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
@@ -125,8 +132,7 @@ func MemSearch(query string, limit int) []MemHit {
|
||||
docs := make([]doc, 0)
|
||||
df := make(map[string]int) // nb de pages contenant chaque terme
|
||||
for _, p := range MemList() {
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
|
||||
content := string(b)
|
||||
content, _ := memPageText(p.Name) // "" si chiffré et verrouillé : matche alors sur le nom seul
|
||||
d := doc{
|
||||
p: p,
|
||||
name: strings.ToLower(p.Name),
|
||||
@@ -246,12 +252,11 @@ func snippetAround(content string, terms []string) string {
|
||||
// MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du
|
||||
// numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500).
|
||||
func MemRead(name string, offset, limit int) (string, error) {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return "", fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return "", fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
@@ -278,11 +283,8 @@ func MemAdd(name, content string) error {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier")
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y
|
||||
@@ -298,12 +300,11 @@ func MemAdd(name, content string) error {
|
||||
var errAlreadyApplied = errors.New("déjà à jour — la page contient déjà cette modification")
|
||||
|
||||
func MemEdit(name, oldText, newText string) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
content := string(b)
|
||||
@@ -323,17 +324,13 @@ func MemEdit(name, oldText, newText string) error {
|
||||
return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
return os.WriteFile(p, []byte(updated), 0o644)
|
||||
return writeMemFile(name, []byte(updated))
|
||||
}
|
||||
|
||||
// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par
|
||||
// l'éditeur web.
|
||||
func MemContent(name string) string {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -353,15 +350,8 @@ func MemSave(name, old, content string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée,
|
||||
@@ -387,47 +377,69 @@ func MemDelete(name string) error {
|
||||
}
|
||||
|
||||
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
|
||||
// mode agent (shell). Trois modes :
|
||||
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
|
||||
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
|
||||
// mode agent (shell). Réglé PAR PROJET (voir projectMemMode). Quatre modes :
|
||||
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
|
||||
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
|
||||
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
|
||||
// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
|
||||
// - MemAlways : proactif, INDEX INJECTÉ — l'index MEMORY.md (titres +
|
||||
// accroches) est en tête de conversation, l'IA lit directement la bonne page.
|
||||
// - MemSearchFirst : proactif, RECHERCHE D'ABORD — rien n'est injecté, l'IA
|
||||
// appelle mem_search avant toute tâche. Prompt léger, démarrage plus rapide.
|
||||
type MemMode string
|
||||
|
||||
const (
|
||||
MemOff MemMode = "off"
|
||||
MemOnDemand MemMode = "ondemand"
|
||||
MemAlways MemMode = "always"
|
||||
MemOff MemMode = "off"
|
||||
MemOnDemand MemMode = "ondemand"
|
||||
MemAlways MemMode = "always"
|
||||
MemSearchFirst MemMode = "search"
|
||||
)
|
||||
|
||||
// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement
|
||||
// actuel). Toute valeur inconnue retombe sur always.
|
||||
func memMode() MemMode {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
|
||||
// memProactive : l'IA gère sa mémoire d'elle-même (cherche/sauve sans qu'on le
|
||||
// demande). Vrai pour les deux modes auto (injecté ou recherche).
|
||||
func memProactive(m MemMode) bool { return m == MemAlways || m == MemSearchFirst }
|
||||
|
||||
// normalizeMemMode ramène une valeur de config à un mode connu. Défaut = always
|
||||
// (préserve le comportement historique). "auto" est un alias d'always.
|
||||
func normalizeMemMode(v string) MemMode {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "off":
|
||||
return MemOff
|
||||
case "ondemand":
|
||||
return MemOnDemand
|
||||
case "search":
|
||||
return MemSearchFirst
|
||||
default: // "always", "auto", "" et inconnus
|
||||
return MemAlways
|
||||
}
|
||||
}
|
||||
|
||||
// setMemMode persiste le mode mémoire dans config.env.
|
||||
func setMemMode(m MemMode) error {
|
||||
return SetConfigKey("MEM_MODE", string(m))
|
||||
// memMode renvoie le mode mémoire du PROJET ACTIF (AJEAN 0.13.12). Repli : un
|
||||
// projet sans réglage propre (tous ceux d'avant) retombe sur l'ancien réglage
|
||||
// global MEM_MODE de config.env — always par défaut. Rien ne change donc pour
|
||||
// les projets existants tant qu'on ne touche pas à leur mode.
|
||||
func memMode() MemMode {
|
||||
if pm := projectMemMode(activeProjectSlug()); pm != "" {
|
||||
return normalizeMemMode(pm)
|
||||
}
|
||||
return normalizeMemMode(ReadConfig()["MEM_MODE"])
|
||||
}
|
||||
|
||||
// cmdMemory : loki memory [off|ondemand|always|status]
|
||||
// setMemMode persiste le mode mémoire du PROJET ACTIF.
|
||||
func setMemMode(m MemMode) error {
|
||||
return setProjectMemMode(activeProjectSlug(), string(m))
|
||||
}
|
||||
|
||||
// cmdMemory : loki memory [off|ondemand|always|search|status]
|
||||
func cmdMemory(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
}
|
||||
label := map[MemMode]string{
|
||||
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
|
||||
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
|
||||
MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
|
||||
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
|
||||
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
|
||||
MemAlways: "auto/injectée (index en tête de conversation, l'IA lit directement la bonne page)",
|
||||
MemSearchFirst: "auto/recherche (rien d'injecté, l'IA cherche avant toute tâche — prompt léger)",
|
||||
}
|
||||
switch sub {
|
||||
case "off":
|
||||
@@ -438,20 +450,24 @@ func cmdMemory(args []string) error {
|
||||
if err := setMemMode(MemOnDemand); err != nil {
|
||||
return err
|
||||
}
|
||||
case "always":
|
||||
case "always", "auto", "inject", "injected":
|
||||
if err := setMemMode(MemAlways); err != nil {
|
||||
return err
|
||||
}
|
||||
case "search", "recherche", "lazy":
|
||||
if err := setMemMode(MemSearchFirst); err != nil {
|
||||
return err
|
||||
}
|
||||
case "", "status":
|
||||
m := memMode()
|
||||
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
|
||||
fmt.Printf("%s projet %s, mode: %s — %s\n", cyan("Mémoire"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
|
||||
pages := MemList()
|
||||
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("usage: loki memory [off|ondemand|always|status]")
|
||||
return fmt.Errorf("usage: loki memory [off|ondemand|always|search|status]")
|
||||
}
|
||||
m := memMode()
|
||||
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
|
||||
fmt.Printf("%s mémoire du projet %s : %s — %s\n", green("[ok]"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
package loki
|
||||
|
||||
// chat_persist.go — l'écriture de la discussion SORT du chemin du premier token.
|
||||
//
|
||||
// persist() faisait tout en ligne : marshal, puis putStoreBytes (ouverture de la
|
||||
// base, commit, fsync), puis convTouchMeta (relecture de l'index, second commit,
|
||||
// second fsync). Dans StartTurn, c'était AVANT `go c.generate` : 26-30 ms
|
||||
// mesurés sur un NVMe Windows, bien davantage sur le /mnt/user d'Unraid (FUSE)
|
||||
// ou un disque de parité — payés à chaque message avant que la requête ne parte
|
||||
// vers llama-server. Même facture au milieu d'un tour (vérification du mode
|
||||
// Code) et pour chaque résultat d'outil coupé (« voir plus »).
|
||||
//
|
||||
// Désormais :
|
||||
// - l'INSTANTANÉ reste synchrone et pris sous c.mu (images → références,
|
||||
// marshal, identifiant de la discussion) : rien ne peut le muter pendant
|
||||
// qu'on le prend, et il dit toujours à quelle discussion il appartient ;
|
||||
// - seule l'E/S bbolt part à un écrivain UNIQUE, qui fusionne ce qui s'est
|
||||
// accumulé (dernier instantané de chaque discussion + résultats d'outils) en
|
||||
// UNE transaction — un fsync au lieu de deux, ou de vingt sur un tour d'agent ;
|
||||
// - seuls les chemins chauds sont asynchrones (StartTurn, persistance en cours
|
||||
// de tour, résultats d'outils). Fin de tour, reset, bascules et suppression
|
||||
// restent synchrones : ils attendent que l'écrivain ait tout vidé, eux
|
||||
// compris. Aucun n'est sur le chemin du premier token, et un redémarrage
|
||||
// juste après eux ne doit rien perdre.
|
||||
//
|
||||
// Ce qui atteint le modèle ne change pas d'un octet : il lit c.Messages en
|
||||
// mémoire, et les résultats complets des outils ne servent qu'au « voir plus »
|
||||
// de l'UI. Le risque accepté : un crash dans les millisecondes qui suivent un
|
||||
// envoi peut perdre le message tout juste envoyé — le même que celui d'un crash
|
||||
// en pleine génération, avant ce changement, pour la réponse.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// convSnap est un instantané complet d'une discussion, prêt à écrire.
|
||||
type convSnap struct {
|
||||
path string // base visée, figée à la prise (voir withDBAt)
|
||||
id string // discussion à laquelle appartient le contenu
|
||||
seq uint64 // ordre de prise : le plus grand l'emporte toujours
|
||||
body []byte // JSON de Conversation
|
||||
title string // titre déduit du premier message (convSummary)
|
||||
turns int
|
||||
project string // projet d'une entrée d'index à créer (voir convTouchMetaIn)
|
||||
}
|
||||
|
||||
// toolResJob est un résultat d'outil complet à ranger dans bkToolRes.
|
||||
type toolResJob struct {
|
||||
path string
|
||||
key string
|
||||
plain string
|
||||
}
|
||||
|
||||
// convPersister sérialise TOUTES les écritures de discussion. Un seul écrivain à
|
||||
// la fois : l'ordre des instantanés ne dépend donc que de seq, jamais d'une
|
||||
// course entre deux goroutines d'écriture.
|
||||
type convPersister struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
snaps map[string]*convSnap // dernier instantané en attente, par discussion
|
||||
res []toolResJob // résultats d'outils en attente, dans l'ordre
|
||||
mem map[string]string // résultats pas encore écrits : « voir plus » les sert d'ici
|
||||
written map[string]uint64 // seq du dernier instantané écrit, par discussion
|
||||
deleted map[string]bool // discussions supprimées : plus rien ne doit les réécrire
|
||||
queued uint64 // tickets délivrés
|
||||
done uint64 // tickets traités (écrits, abandonnés ou en échec)
|
||||
running bool
|
||||
}
|
||||
|
||||
var (
|
||||
persistQ = newConvPersister()
|
||||
// persistSeq numérote les instantanés. Incrémenté SOUS c.mu : l'ordre des
|
||||
// numéros est donc celui des états de la discussion.
|
||||
persistSeq atomic.Uint64
|
||||
// persistCommit écrit un lot ; remplaçable par les tests pour observer
|
||||
// l'ordre des écritures ou les ralentir.
|
||||
persistCommit = commitPersistBatch
|
||||
)
|
||||
|
||||
func newConvPersister() *convPersister {
|
||||
p := &convPersister{
|
||||
snaps: map[string]*convSnap{},
|
||||
mem: map[string]string{},
|
||||
written: map[string]uint64{},
|
||||
deleted: map[string]bool{},
|
||||
}
|
||||
p.cond = sync.NewCond(&p.mu)
|
||||
return p
|
||||
}
|
||||
|
||||
// enqueueSnap confie un instantané à l'écrivain et renvoie son ticket. Un
|
||||
// instantané en attente pour la même discussion est remplacé par le plus récent :
|
||||
// écrire l'ancien ne servirait qu'à l'écraser aussitôt.
|
||||
func (p *convPersister) enqueueSnap(s *convSnap) uint64 {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if !p.deleted[s.id] {
|
||||
if cur := p.snaps[s.id]; cur == nil || s.seq > cur.seq {
|
||||
p.snaps[s.id] = s
|
||||
}
|
||||
}
|
||||
return p.kickLocked()
|
||||
}
|
||||
|
||||
// enqueueToolRes confie un résultat d'outil à l'écrivain. Il est servi depuis
|
||||
// la mémoire tant qu'il n'est pas sur disque.
|
||||
func (p *convPersister) enqueueToolRes(j toolResJob) uint64 {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
// Discussion déjà supprimée (un outil finit juste après la suppression) :
|
||||
// l'écrivain l'écarterait, et la copie en mémoire ne partirait plus jamais.
|
||||
if !p.deleted[toolResConv(j.key)] {
|
||||
p.mem[j.key] = j.plain
|
||||
p.res = append(p.res, j)
|
||||
}
|
||||
return p.kickLocked()
|
||||
}
|
||||
|
||||
// kickLocked délivre un ticket et démarre l'écrivain s'il dort. p.mu détenu.
|
||||
func (p *convPersister) kickLocked() uint64 {
|
||||
p.queued++
|
||||
if !p.running {
|
||||
p.running = true
|
||||
go p.run()
|
||||
}
|
||||
return p.queued
|
||||
}
|
||||
|
||||
// run vide la file par lots, jusqu'à ce qu'elle soit vide.
|
||||
func (p *convPersister) run() {
|
||||
for {
|
||||
p.mu.Lock()
|
||||
if len(p.snaps) == 0 && len(p.res) == 0 {
|
||||
p.running = false
|
||||
p.done = p.queued
|
||||
p.cond.Broadcast()
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
upto := p.queued
|
||||
snaps := make([]*convSnap, 0, len(p.snaps))
|
||||
for id, s := range p.snaps {
|
||||
if !p.deleted[id] && s.seq > p.written[id] {
|
||||
snaps = append(snaps, s)
|
||||
}
|
||||
}
|
||||
res := make([]toolResJob, 0, len(p.res))
|
||||
for _, j := range p.res {
|
||||
if !p.deleted[toolResConv(j.key)] {
|
||||
res = append(res, j)
|
||||
}
|
||||
}
|
||||
p.snaps, p.res = map[string]*convSnap{}, nil
|
||||
p.mu.Unlock()
|
||||
|
||||
sort.Slice(snaps, func(i, j int) bool { return snaps[i].seq < snaps[j].seq })
|
||||
wrote, err := persistCommitByPath(snaps, res)
|
||||
|
||||
p.mu.Lock()
|
||||
for _, s := range wrote {
|
||||
if s.seq > p.written[s.id] {
|
||||
p.written[s.id] = s.seq
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// Les résultats restent servis depuis la mémoire : le « voir plus » de
|
||||
// cette session marche encore, seul un redémarrage les perdrait. Un
|
||||
// instantané raté sera remplacé par le suivant (fin de tour au plus tard).
|
||||
fmt.Fprintf(os.Stderr, "[persist] écriture différée échouée (%d instantané(s), %d résultat(s) d'outil gardé(s) en mémoire) : %v\n", len(snaps)-len(wrote), len(res), err)
|
||||
} else {
|
||||
for _, j := range res {
|
||||
if p.mem[j.key] == j.plain {
|
||||
delete(p.mem, j.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.done = upto
|
||||
p.cond.Broadcast()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// wait bloque jusqu'à ce que le ticket (et tous ceux d'avant) soit traité.
|
||||
func (p *convPersister) wait(ticket uint64) {
|
||||
p.mu.Lock()
|
||||
for p.done < ticket {
|
||||
p.cond.Wait()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// flush attend que tout ce qui a été confié jusqu'ici soit traité.
|
||||
func (p *convPersister) flush() {
|
||||
p.mu.Lock()
|
||||
t := p.queued
|
||||
p.mu.Unlock()
|
||||
p.wait(t)
|
||||
}
|
||||
|
||||
// forget marque une discussion comme supprimée : ses instantanés et résultats en
|
||||
// attente sont jetés, et plus rien ne la réécrira — sans ça, un instantané en
|
||||
// vol la ferait renaître (contenu ET entrée d'index) juste après sa suppression.
|
||||
func (p *convPersister) forget(id string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.deleted[id] = true
|
||||
delete(p.snaps, id)
|
||||
p.dropToolResLocked(id + ".")
|
||||
}
|
||||
|
||||
// dropToolRes oublie les résultats en attente d'une discussion.
|
||||
func (p *convPersister) dropToolRes(prefix string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.dropToolResLocked(prefix)
|
||||
}
|
||||
|
||||
func (p *convPersister) dropToolResLocked(prefix string) {
|
||||
for k := range p.mem {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(p.mem, k)
|
||||
}
|
||||
}
|
||||
kept := p.res[:0]
|
||||
for _, j := range p.res {
|
||||
if !strings.HasPrefix(j.key, prefix) {
|
||||
kept = append(kept, j)
|
||||
}
|
||||
}
|
||||
p.res = kept
|
||||
}
|
||||
|
||||
// toolResPending relit un résultat pas encore sur disque.
|
||||
func (p *convPersister) toolResPending(key string) (string, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
s, ok := p.mem[key]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// toolResConv extrait l'identifiant de discussion d'une clé « <id>.<aléa> ».
|
||||
func toolResConv(key string) string {
|
||||
if i := strings.LastIndexByte(key, '.'); i > 0 {
|
||||
return key[:i]
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// persistExitWait borne l'attente de l'écrivain avant une sortie : le délai
|
||||
// d'ouverture de la base (5 s, withDBAt) plus une marge.
|
||||
const persistExitWait = 6 * time.Second
|
||||
|
||||
// flushPersister attend que les écritures différées soient sur disque, au plus
|
||||
// d pour ne jamais retenir un arrêt sur une base bloquée. À appeler avant toute
|
||||
// sortie volontaire du process (redémarrage, « Quitter », exec).
|
||||
func flushPersister(d time.Duration) {
|
||||
ok := make(chan struct{})
|
||||
go func() {
|
||||
persistQ.flush()
|
||||
close(ok)
|
||||
}()
|
||||
select {
|
||||
case <-ok:
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
// persistCommitByPath regroupe le lot par base (une seule en production) et
|
||||
// l'écrit. Renvoie les instantanés effectivement écrits.
|
||||
func persistCommitByPath(snaps []*convSnap, res []toolResJob) ([]*convSnap, error) {
|
||||
paths := map[string]bool{}
|
||||
for _, s := range snaps {
|
||||
paths[s.path] = true
|
||||
}
|
||||
for _, j := range res {
|
||||
paths[j.path] = true
|
||||
}
|
||||
var wrote []*convSnap
|
||||
var firstErr error
|
||||
for path := range paths {
|
||||
var ps []*convSnap
|
||||
var pr []toolResJob
|
||||
for _, s := range snaps {
|
||||
if s.path == path {
|
||||
ps = append(ps, s)
|
||||
}
|
||||
}
|
||||
for _, j := range res {
|
||||
if j.path == path {
|
||||
pr = append(pr, j)
|
||||
}
|
||||
}
|
||||
w, err := persistCommit(path, ps, pr)
|
||||
wrote = append(wrote, w...)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return wrote, firstErr
|
||||
}
|
||||
|
||||
// commitPersistBatch écrit un lot en UNE transaction : les résultats d'outils,
|
||||
// puis chaque instantané avec la mise à jour de son entrée d'index. Avant, le
|
||||
// contenu et l'index étaient deux commits (deux fsync), et la relecture de
|
||||
// l'index hors de tout verrou pouvait écraser un renommage concurrent.
|
||||
//
|
||||
// Mémoire chiffrée mais verrouillée : rien n'est écrit en clair, comme
|
||||
// putStoreBytes — les instantanés sont sautés (le fil reste en RAM), les
|
||||
// résultats d'outils renvoient une erreur et restent servis depuis la mémoire.
|
||||
func commitPersistBatch(path string, snaps []*convSnap, res []toolResJob) ([]*convSnap, error) {
|
||||
// Hors transaction : memEncActive relit la configuration, donc rouvrirait la
|
||||
// base — bbolt n'est pas rentrant (voir memEncoderNow).
|
||||
encode, encErr := memEncoderNow()
|
||||
if len(res) > 0 && encErr != nil {
|
||||
return nil, encErr
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
convIndexMu.Lock()
|
||||
defer convIndexMu.Unlock()
|
||||
cacheBust(bkChat)
|
||||
cacheBust(bkToolRes)
|
||||
var wrote []*convSnap
|
||||
err := withDBAt(path, func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
wrote = wrote[:0]
|
||||
if len(res) > 0 {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkToolRes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, j := range res {
|
||||
enc, err := encode([]byte(j.plain))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Put([]byte(j.key), enc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(snaps) == 0 || encErr != nil {
|
||||
return nil
|
||||
}
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkChat))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Index illisible (chiffré par une autre clé, abîmé) : on écrit le
|
||||
// contenu mais on ne touche PAS à l'index — le réécrire depuis une
|
||||
// lecture vide effacerait toutes les autres discussions de la liste.
|
||||
var idx []convMeta
|
||||
idxOK := true
|
||||
if raw := b.Get([]byte(ckIndex)); len(raw) > 0 {
|
||||
dec, err := decodeMemContent(raw)
|
||||
if err != nil || json.Unmarshal(dec, &idx) != nil {
|
||||
idxOK = false
|
||||
}
|
||||
}
|
||||
for _, s := range snaps {
|
||||
enc, err := encode(s.body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Put([]byte(convKey(s.id)), enc); err != nil {
|
||||
return err
|
||||
}
|
||||
if idxOK {
|
||||
idx = convTouchMetaIn(idx, s, now)
|
||||
}
|
||||
wrote = append(wrote, s)
|
||||
}
|
||||
if !idxOK {
|
||||
return nil
|
||||
}
|
||||
ib, err := json.Marshal(idx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc, err := encode(ib)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(ckIndex), enc)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrote, nil
|
||||
}
|
||||
|
||||
// convTouchMetaIn rafraîchit l'entrée d'index d'un instantané : date, nombre
|
||||
// d'échanges, titre déduit si l'utilisateur n'en a pas choisi. Une discussion
|
||||
// absente de l'index y est ajoutée — c'est ce qui recolle une discussion créée
|
||||
// mémoire verrouillée (son entrée n'avait pas pu s'écrire). Une discussion
|
||||
// SUPPRIMÉE n'arrive jamais ici : l'écrivain l'a écartée (forget).
|
||||
func convTouchMetaIn(idx []convMeta, s *convSnap, now int64) []convMeta {
|
||||
for i := range idx {
|
||||
if idx[i].ID != s.id {
|
||||
continue
|
||||
}
|
||||
idx[i].Updated = now
|
||||
idx[i].Turns = s.turns
|
||||
if idx[i].Title == "" {
|
||||
idx[i].Title = s.title
|
||||
}
|
||||
return idx
|
||||
}
|
||||
return append(idx, convMeta{ID: s.id, Title: s.title, Created: now, Updated: now, Turns: s.turns, Project: s.project})
|
||||
}
|
||||
|
||||
// --- Discussion active, gardée en RAM -----------------------------------------
|
||||
//
|
||||
// convEnsureActive relisait bkChat/active à CHAQUE appel : une ouverture de base
|
||||
// (3-4 ms sur Windows) pour le mode Code, l'espace de travail, les résultats
|
||||
// d'outils, la télémétrie… plusieurs fois par tour. Un seul process possède la
|
||||
// conversation (relay_link.go) et toutes les bascules passent par
|
||||
// convActivate : le cache ne peut donc pas diverger de la base, exactement
|
||||
// comme activeProjCache pour le projet actif.
|
||||
//
|
||||
// Le cache sert aussi de LIEN entre le contenu en mémoire et son identifiant :
|
||||
// convActivate le change sous c.mu, en même temps que le contenu (loadFrom). Un
|
||||
// instantané pris sous c.mu lit donc toujours l'identifiant du contenu qu'il
|
||||
// sérialise — jamais l'ancien fil sous le nouvel identifiant, même si une
|
||||
// bascule s'intercale entre deux étapes de persist.
|
||||
|
||||
type convActiveRef struct{ path, id string }
|
||||
|
||||
var (
|
||||
// convActiveMu sérialise la création de l'identifiant et les bascules.
|
||||
// Ordre des verrous : convActiveMu, puis c.mu (loadFrom). Jamais l'inverse —
|
||||
// d'où la lecture sans verrou du cache chaud.
|
||||
convActiveMu sync.Mutex
|
||||
convActiveCache atomic.Pointer[convActiveRef]
|
||||
// convIndexMu sérialise les lectures-modifications-écritures de l'index :
|
||||
// celles des opérations de discussion et celle de l'écrivain.
|
||||
convIndexMu sync.Mutex
|
||||
)
|
||||
|
||||
// convActiveCached renvoie l'identifiant en cache pour la base courante, ou "".
|
||||
func convActiveCached() string {
|
||||
if r := convActiveCache.Load(); r != nil && r.path == dbPath() {
|
||||
return r.id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// convActiveRemember met l'identifiant en cache. Un pointeur encore chiffré par
|
||||
// une ancienne version (healPlainStoreKeys) n'est pas mis en cache : il sera
|
||||
// relu en clair après la réparation.
|
||||
func convActiveRemember(id string) {
|
||||
if id == "" || looksEncrypted([]byte(id)) {
|
||||
convActiveCache.Store(nil)
|
||||
return
|
||||
}
|
||||
convActiveCache.Store(&convActiveRef{path: dbPath(), id: id})
|
||||
}
|
||||
|
||||
// convActiveID lit la discussion active SANS jamais en créer une : cache chaud,
|
||||
// sinon la clé en base (vide si aucune). Pour les lecteurs qui, avant le cache,
|
||||
// lisaient la clé brute — les résultats d'outils d'une tâche de fond sur une base
|
||||
// neuve vont sous « nosession » au lieu de forger une discussion vide.
|
||||
func convActiveID() string {
|
||||
if id := convActiveCached(); id != "" {
|
||||
return id
|
||||
}
|
||||
return getStr(bkChat, ckActive)
|
||||
}
|
||||
|
||||
// convActivate fait de id la discussion active et charge b en mémoire, sous
|
||||
// convActiveMu : le pointeur en base, le cache et le contenu changent ensemble.
|
||||
func convActivate(id string, b []byte) {
|
||||
convActiveMu.Lock()
|
||||
defer convActiveMu.Unlock()
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(id, b)
|
||||
}
|
||||
|
||||
// snapshot prend l'instantané à écrire. L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) snapshot() *convSnap {
|
||||
// Hors verrou : au premier appel, convEnsureActive lit (voire crée) la clé
|
||||
// en base. Ensuite le cache est chaud et relu sous c.mu.
|
||||
id0 := convEnsureActive()
|
||||
project := activeProjectSlug()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
id := id0
|
||||
if c == conv {
|
||||
if cid := convActiveCached(); cid != "" {
|
||||
id = cid
|
||||
}
|
||||
}
|
||||
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
|
||||
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
|
||||
refImagesInMessages(c.Messages)
|
||||
// En plein tour, le journal porte encore un événement par token et chaque
|
||||
// frappe d'outil : on écrit la forme que produira la fin de tour
|
||||
// (compactLog), sans toucher au journal en mémoire que suivent les abonnés.
|
||||
full := c.Log
|
||||
if c.Generating {
|
||||
c.Log = compactLog(full)
|
||||
}
|
||||
b, err := json.Marshal(c)
|
||||
c.Log = full
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
turns := 0
|
||||
for _, ev := range full {
|
||||
if _, ok := ev.Delta["user"]; ok {
|
||||
turns++
|
||||
}
|
||||
}
|
||||
return &convSnap{
|
||||
path: dbPath(),
|
||||
id: id,
|
||||
seq: persistSeq.Add(1),
|
||||
body: b,
|
||||
title: convSummary(c.Messages),
|
||||
turns: turns,
|
||||
project: project,
|
||||
}
|
||||
}
|
||||
|
||||
// persist enregistre l'état sous sa discussion et ATTEND l'écriture : fin de
|
||||
// tour, reset, bascules, compactage manuel. Tout ce qui était en attente avant
|
||||
// lui est écrit aussi. L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) persist() {
|
||||
if s := c.snapshot(); s != nil {
|
||||
persistQ.wait(persistQ.enqueueSnap(s))
|
||||
return
|
||||
}
|
||||
persistQ.flush()
|
||||
}
|
||||
|
||||
// persistAsync prend l'instantané tout de suite mais laisse l'écriture à
|
||||
// l'écrivain : pour les chemins qui précèdent un appel au modèle (StartTurn,
|
||||
// persistance en cours de tour). L'identifiant de la discussion est résolu ici,
|
||||
// de façon synchrone — sur une base neuve, generate trouve donc la discussion
|
||||
// active déjà créée, comme avant. L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) persistAsync() {
|
||||
if s := c.snapshot(); s != nil {
|
||||
persistQ.enqueueSnap(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// gatePersist remplace l'écriture par une version qu'on retient : chaque lot
|
||||
// signale son arrivée sur entered puis attend que release soit fermé. Les
|
||||
// échanges de persistCommit se font écrivain vidé : aucun lot ne le lit alors.
|
||||
func gatePersist(t *testing.T) (entered chan []*convSnap, release chan struct{}) {
|
||||
t.Helper()
|
||||
entered = make(chan []*convSnap, 64)
|
||||
release = make(chan struct{})
|
||||
persistQ.flush()
|
||||
prev := persistCommit
|
||||
persistCommit = func(path string, s []*convSnap, r []toolResJob) ([]*convSnap, error) {
|
||||
entered <- s
|
||||
<-release
|
||||
return prev(path, s, r)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
persistQ.flush()
|
||||
persistCommit = prev
|
||||
})
|
||||
return entered, release
|
||||
}
|
||||
|
||||
// waitEntered attend qu'un lot arrive à l'écriture.
|
||||
func waitEntered(t *testing.T, entered chan []*convSnap) []*convSnap {
|
||||
t.Helper()
|
||||
select {
|
||||
case s := <-entered:
|
||||
return s
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("aucun lot n'a atteint l'écriture")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// diskMessages relit le nombre de messages et le texte d'une discussion en base.
|
||||
func diskMessages(t *testing.T, id string) (int, string) {
|
||||
t.Helper()
|
||||
b := storeBytes(bkChat, convKey(id))
|
||||
if len(b) == 0 {
|
||||
return -1, ""
|
||||
}
|
||||
var st struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(st.Messages), string(b)
|
||||
}
|
||||
|
||||
func addUserMsg(text string) {
|
||||
conv.mu.Lock()
|
||||
conv.Messages = append(conv.Messages, Message{Role: "user", Content: text})
|
||||
conv.Log = append(conv.Log, LogEvent{Seq: len(conv.Log) + 1, Delta: map[string]any{"user": text}})
|
||||
conv.mu.Unlock()
|
||||
}
|
||||
|
||||
// Des instantanés pris en rafale depuis plusieurs goroutines sont écrits dans
|
||||
// l'ordre où ils ont été pris : jamais un plus ancien après un plus récent, et
|
||||
// la base finit sur le dernier état.
|
||||
func TestPersisterNEcritJamaisUnEtatPlusAncien(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
persistQ.flush()
|
||||
var mu sync.Mutex
|
||||
var seqs []uint64
|
||||
prev := persistCommit
|
||||
persistCommit = func(path string, s []*convSnap, r []toolResJob) ([]*convSnap, error) {
|
||||
mu.Lock()
|
||||
for _, x := range s {
|
||||
seqs = append(seqs, x.seq)
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(2 * time.Millisecond) // laisse la file s'accumuler
|
||||
return prev(path, s, r)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
persistQ.flush()
|
||||
persistCommit = prev
|
||||
})
|
||||
|
||||
const workers, per = 8, 10
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < workers; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < per; i++ {
|
||||
addUserMsg(fmt.Sprintf("g%d-%d", g, i))
|
||||
conv.persistAsync()
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
persistQ.flush()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i := 1; i < len(seqs); i++ {
|
||||
if seqs[i] <= seqs[i-1] {
|
||||
t.Fatalf("instantané %d écrit après %d : un état plus ancien a écrasé un plus récent", seqs[i], seqs[i-1])
|
||||
}
|
||||
}
|
||||
if n, _ := diskMessages(t, convEnsureActive()); n != workers*per {
|
||||
t.Fatalf("la base finit sur %d messages, attendu %d (dernier état)", n, workers*per)
|
||||
}
|
||||
}
|
||||
|
||||
// persist (fin de tour, reset, bascule) est SYNCHRONE : il attend l'écriture en
|
||||
// vol, puis la sienne. Au retour de Reset, la base porte déjà le fil vide.
|
||||
func TestPersistSynchroneAttendLesEcrituresEnVol(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
entered, release := gatePersist(t)
|
||||
addUserMsg("avant le reset")
|
||||
conv.persistAsync()
|
||||
waitEntered(t, entered) // l'écrivain est retenu au milieu du lot
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
conv.Reset()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Reset est revenu avant que l'écriture en vol soit terminée")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Reset bloqué")
|
||||
}
|
||||
if n, _ := diskMessages(t, convEnsureActive()); n != 0 {
|
||||
t.Fatalf("au retour de Reset, la base doit porter le fil vide (%d message(s))", n)
|
||||
}
|
||||
}
|
||||
|
||||
// « Voir plus » marche avant même que le résultat soit sur disque, et la copie
|
||||
// en mémoire disparaît une fois l'écriture faite.
|
||||
func TestToolResultServiAvantLEcriture(t *testing.T) {
|
||||
testHome(t)
|
||||
entered, release := gatePersist(t)
|
||||
long := strings.Repeat("sortie\n", 1000)
|
||||
id := saveToolResult(long)
|
||||
if id == "" {
|
||||
t.Fatal("résultat non enregistré")
|
||||
}
|
||||
waitEntered(t, entered)
|
||||
if got, ok := loadToolResult(id); !ok || got != long {
|
||||
t.Fatal("le résultat doit être servi depuis la mémoire tant qu'il n'est pas écrit")
|
||||
}
|
||||
close(release)
|
||||
persistQ.flush()
|
||||
if _, ok := persistQ.toolResPending(id); ok {
|
||||
t.Fatal("la copie en mémoire doit partir une fois le résultat écrit")
|
||||
}
|
||||
if got, ok := loadToolResult(id); !ok || got != long {
|
||||
t.Fatal("résultat perdu après l'écriture")
|
||||
}
|
||||
}
|
||||
|
||||
// Un résultat supprimé avec sa discussion avant d'être écrit n'est jamais écrit.
|
||||
func TestToolResultSupprimeAvantEcriture(t *testing.T) {
|
||||
testHome(t)
|
||||
entered, release := gatePersist(t)
|
||||
saveToolResult(strings.Repeat("a", 3000)) // retient l'écrivain
|
||||
waitEntered(t, entered)
|
||||
second := saveToolResult(strings.Repeat("b", 3000)) // en attente derrière
|
||||
deleteToolResultsFor(toolResConv(second))
|
||||
close(release)
|
||||
persistQ.flush()
|
||||
if _, ok := loadToolResult(second); ok {
|
||||
t.Fatal("un résultat supprimé avant son écriture a été écrit quand même")
|
||||
}
|
||||
}
|
||||
|
||||
// compactLog rend exactement ce que donne la compaction de fin de tour, sans
|
||||
// toucher au journal d'origine.
|
||||
func TestCompactLogPurEgalFinDeTour(t *testing.T) {
|
||||
log := []LogEvent{
|
||||
{Seq: 1, TS: 10, Delta: map[string]any{"user": "q"}},
|
||||
{Seq: 2, TS: 11, Delta: map[string]any{"reasoning_content": "je ", "toks": 1}},
|
||||
{Seq: 3, TS: 12, Delta: map[string]any{"reasoning_content": "pense", "toks": 1}},
|
||||
{Seq: 4, TS: 13, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "done": false}}},
|
||||
{Seq: 5, TS: 14, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "done": true}}},
|
||||
{Seq: 6, TS: 15, Delta: map[string]any{"content": "bon", "toks": 1}},
|
||||
{Seq: 7, TS: 16, Delta: map[string]any{"content": "jour", "toks": 1}},
|
||||
}
|
||||
orig := append([]LogEvent(nil), log...)
|
||||
got := compactLog(log)
|
||||
if !reflect.DeepEqual(log, orig) {
|
||||
t.Fatal("compactLog a modifié le journal d'origine")
|
||||
}
|
||||
c := newTestConv()
|
||||
c.Log = append([]LogEvent(nil), log...)
|
||||
c.compactLogLocked()
|
||||
if !reflect.DeepEqual(got, c.Log) {
|
||||
t.Fatalf("compactLog diffère de la fin de tour :\n%v\n%v", got, c.Log)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("attendu 4 événements (user, réflexion, outil fini, texte), got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// En plein tour, la base reçoit le journal compacté ; le journal en mémoire
|
||||
// (suivi par les abonnés) garde ses événements bruts.
|
||||
func TestPersistEnPleinTourEcritLeJournalCompacte(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
conv.mu.Lock()
|
||||
conv.Generating = true
|
||||
for i := 0; i < 50; i++ {
|
||||
conv.Log = append(conv.Log, LogEvent{Seq: i + 1, Delta: map[string]any{"content": "x", "toks": 1}})
|
||||
}
|
||||
conv.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
conv.mu.Lock()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
})
|
||||
conv.persistAsync()
|
||||
persistQ.flush()
|
||||
var st struct {
|
||||
Log []LogEvent `json:"log"`
|
||||
}
|
||||
if err := json.Unmarshal(storeBytes(bkChat, convKey(convEnsureActive())), &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.Log) != 1 || st.Log[0].Delta["content"] != strings.Repeat("x", 50) {
|
||||
t.Fatalf("journal écrit non compacté : %d événement(s)", len(st.Log))
|
||||
}
|
||||
conv.mu.Lock()
|
||||
n := len(conv.Log)
|
||||
conv.mu.Unlock()
|
||||
if n != 50 {
|
||||
t.Fatalf("le journal en mémoire ne doit pas être touché : %d événement(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Contenu et entrée d'index partent dans la MÊME écriture.
|
||||
func TestPersistEcritContenuEtIndexEnUneFois(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
persistQ.flush()
|
||||
var calls int
|
||||
var mu sync.Mutex
|
||||
prev := persistCommit
|
||||
persistCommit = func(path string, s []*convSnap, r []toolResJob) ([]*convSnap, error) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
return prev(path, s, r)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
persistQ.flush()
|
||||
persistCommit = prev
|
||||
})
|
||||
id := convEnsureActive()
|
||||
addUserMsg("titre de la discussion")
|
||||
conv.persist()
|
||||
mu.Lock()
|
||||
n := calls
|
||||
mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("%d écritures pour un persist, attendu 1", n)
|
||||
}
|
||||
if c, _ := diskMessages(t, id); c != 1 {
|
||||
t.Fatalf("contenu non écrit (%d)", c)
|
||||
}
|
||||
for _, m := range convIndex() {
|
||||
if m.ID == id {
|
||||
if m.Turns != 1 || m.Title != "titre de la discussion" {
|
||||
t.Fatalf("entrée d'index non rafraîchie : %+v", m)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("discussion absente de l'index")
|
||||
}
|
||||
|
||||
// Supprimer une discussion pendant qu'une écriture la vise ne la fait pas
|
||||
// renaître : ni contenu, ni entrée d'index.
|
||||
func TestSuppressionPendantEcritureNeRessusciteRien(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
a := convEnsureActive()
|
||||
addUserMsg("fil A")
|
||||
conv.persist()
|
||||
b := convNew()
|
||||
if err := convSwitch(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entered, release := gatePersist(t)
|
||||
addUserMsg("fil A, suite")
|
||||
conv.persistAsync()
|
||||
waitEntered(t, entered) // un lot visant A est en vol
|
||||
addUserMsg("fil A, encore")
|
||||
conv.persistAsync() // un autre attend derrière
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- convDelete(a) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(release)
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("convDelete bloqué")
|
||||
}
|
||||
persistQ.flush()
|
||||
if n, _ := diskMessages(t, a); n != -1 {
|
||||
t.Fatalf("la discussion supprimée a été réécrite (%d messages)", n)
|
||||
}
|
||||
for _, m := range convIndex() {
|
||||
if m.ID == a {
|
||||
t.Fatal("la discussion supprimée est revenue dans l'index")
|
||||
}
|
||||
}
|
||||
if got := convEnsureActive(); got != b {
|
||||
t.Fatalf("active = %q, attendu %q", got, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Basculer pendant qu'une écriture est en vol : l'ancien fil est écrit sous SON
|
||||
// identifiant, jamais sous celui de la discussion qu'on ouvre.
|
||||
func TestBasculePendantEcritureGardeChaqueFilChezLui(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
a := convEnsureActive()
|
||||
addUserMsg("seulement dans A")
|
||||
conv.persist()
|
||||
b := convNew()
|
||||
addUserMsg("seulement dans B")
|
||||
conv.persist()
|
||||
if err := convSwitch(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entered, release := gatePersist(t)
|
||||
addUserMsg("A encore")
|
||||
conv.persistAsync()
|
||||
waitEntered(t, entered)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- convSwitch(b) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persistQ.flush()
|
||||
if _, body := diskMessages(t, b); strings.Contains(body, "dans A") {
|
||||
t.Fatal("le fil A a été écrit sous l'identifiant de B")
|
||||
}
|
||||
if n, body := diskMessages(t, a); n != 2 || !strings.Contains(body, "A encore") {
|
||||
t.Fatalf("le fil A n'a pas gardé son dernier état (%d messages)", n)
|
||||
}
|
||||
conv.mu.Lock()
|
||||
got := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("la bascule doit charger B (1 message), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Base neuve : l'instantané de StartTurn crée la discussion active TOUT DE
|
||||
// SUITE, avant l'écriture — generate, qui démarre juste après, voit la même
|
||||
// discussion (mode Code, critères, espace de travail) que la persistance.
|
||||
func TestPersistAsyncCreeLaDiscussionActiveAvantGenerate(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
entered, release := gatePersist(t)
|
||||
defer close(release)
|
||||
addUserMsg("premier message")
|
||||
conv.persistAsync()
|
||||
snaps := waitEntered(t, entered)
|
||||
id := getStr(bkChat, ckActive)
|
||||
if id == "" {
|
||||
t.Fatal("la discussion active doit exister dès le retour de persistAsync")
|
||||
}
|
||||
if got := convEnsureActive(); got != id {
|
||||
t.Fatalf("generate verrait %q, la persistance %q", got, id)
|
||||
}
|
||||
if len(snaps) != 1 || snaps[0].id != id {
|
||||
t.Fatalf("l'instantané vise %v, attendu %q", snaps, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Base neuve, appels simultanés : un seul identifiant est forgé.
|
||||
func TestConvEnsureActiveConcurrentForgeUnSeulID(t *testing.T) {
|
||||
testHome(t)
|
||||
var wg sync.WaitGroup
|
||||
ids := make([]string, 8)
|
||||
for i := range ids {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
ids[i] = convEnsureActive()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, id := range ids[1:] {
|
||||
if id != ids[0] {
|
||||
t.Fatalf("identifiants divergents : %v", ids)
|
||||
}
|
||||
}
|
||||
if n := len(convIndex()); n != 1 {
|
||||
t.Fatalf("%d entrées d'index, attendu 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Un outil qui finit juste après la suppression de sa discussion : son résultat
|
||||
// n'est ni écrit ni gardé en mémoire pour toujours.
|
||||
func TestToolResultApresSuppressionNeFuitPas(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
a := convEnsureActive()
|
||||
convNew()
|
||||
persistQ.forget(a)
|
||||
key := a + ".deadbeef"
|
||||
persistQ.enqueueToolRes(toolResJob{path: dbPath(), key: key, plain: "orphelin"})
|
||||
persistQ.flush()
|
||||
if _, ok := persistQ.toolResPending(key); ok {
|
||||
t.Fatal("résultat d'une discussion supprimée gardé en mémoire")
|
||||
}
|
||||
if b := getBytes(bkToolRes, key); b != nil {
|
||||
t.Fatal("résultat d'une discussion supprimée écrit sur disque")
|
||||
}
|
||||
}
|
||||
|
||||
// Base neuve, sans discussion active : un résultat d'outil (tâche de fond) ne
|
||||
// forge PAS de discussion vide dans la liste — il va sous « nosession », comme
|
||||
// avant le cache.
|
||||
func TestSaveToolResultNeCreePasDeDiscussion(t *testing.T) {
|
||||
testHome(t)
|
||||
id := saveToolResult(strings.Repeat("r", 3000))
|
||||
if !strings.HasPrefix(id, "nosession.") {
|
||||
t.Fatalf("id = %q, attendu nosession.*", id)
|
||||
}
|
||||
persistQ.flush()
|
||||
if getStr(bkChat, ckActive) != "" || len(convIndex()) != 0 {
|
||||
t.Fatal("saveToolResult a créé une discussion")
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,36 @@ import (
|
||||
// et recherches déjà faites. Il récupère bien plus qu'il ne coûte.
|
||||
// 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre
|
||||
// actions — quatre outils auraient coûté quatre fois ça.
|
||||
// 8600 → 11000 : les quatre outils task_* (tasks_tools.go), ~1290 car. L'IA se
|
||||
// donne elle-même ses rappels et ses veilles ; sans eux il fallait
|
||||
// passer par l'interface. Descriptions déjà au plus court, et un
|
||||
// seul schéma par action (create/update/delete ne partagent pas
|
||||
// assez d'arguments pour fusionner sans rendre chacun ambigu).
|
||||
// Les ~500 car restants couvrent web_screenshot, déclaré dès que
|
||||
// Playwright est présent — c'est le cas dans l'image, pas sur le
|
||||
// runner de CI : le budget doit tenir pour l'image, la vraie.
|
||||
// (Le pilotage de navigateur, lui, est HORS de ce décompte : ses
|
||||
// schémas n'existent que si on arme son interrupteur.)
|
||||
//
|
||||
// Descriptions déjà réduites au strict nécessaire dans les deux cas. La consigne
|
||||
// d'origine tient pour la suite : ce budget ne se relève pas pour du prompt.
|
||||
const promptCharBudget = 8600 // ~2150 tokens, tout allumé
|
||||
// Les ~200 car de prompt qui les accompagnent sont le seul écart
|
||||
// assumé à la règle « pas de relèvement pour du prompt » : un outil
|
||||
// qu'aucune ligne ne mentionne n'est jamais appelé de lui-même, et
|
||||
// la ligne dit ce que les schémas ne disent pas (quand se planifier,
|
||||
// et qu'un script planifié ne charge aucun modèle). Elle a été
|
||||
// ramenée de trois lignes à une.
|
||||
//
|
||||
// Descriptions déjà réduites au strict nécessaire. La consigne d'origine tient
|
||||
// pour la suite : ce budget ne se relève pas pour du prompt.
|
||||
const promptCharBudget = 11000 // ~2750 tokens, tout allumé
|
||||
|
||||
func TestSystemPromptStaysLean(t *testing.T) {
|
||||
// $LOKI_HOME isolé : les outils MCP configurés sur la machine de test (et
|
||||
// leur ligne au préambule) ne comptent pas dans le budget de Loki.
|
||||
testHome(t)
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
sp := baseSystemPrompt(caps)
|
||||
tb, err := json.Marshal(EnabledTools(caps))
|
||||
tools := EnabledTools(caps)
|
||||
sp := baseSystemPrompt(caps, tools)
|
||||
tb, err := json.Marshal(tools)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -52,7 +73,7 @@ func TestSystemPromptStaysLean(t *testing.T) {
|
||||
// Le prompt ne doit pas redevenir un catalogue d'outils : leurs schémas partent
|
||||
// dans la MÊME requête et les décrivent déjà un par un.
|
||||
func TestSystemPromptDoesNotRelistTools(t *testing.T) {
|
||||
sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways})
|
||||
sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways}, nil)
|
||||
for _, line := range strings.Split(sp, "\n") {
|
||||
l := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(l, "- ") {
|
||||
@@ -71,7 +92,7 @@ func TestSystemPromptDoesNotRelistTools(t *testing.T) {
|
||||
// ordonne d'appeler des outils invente des appels textuels qui fuient dans la
|
||||
// réponse.
|
||||
func TestSystemPromptEmptyWithoutTools(t *testing.T) {
|
||||
if sp := baseSystemPrompt(Caps{Mem: MemOff}); sp != "" {
|
||||
if sp := baseSystemPrompt(Caps{Mem: MemOff}, nil); sp != "" {
|
||||
t.Fatalf("préambule non vide sans outils : %q", sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Pendant une génération de chat, un envoi est mis en file ; il ressort à la
|
||||
// frontière d'étape suivante, journalisé comme une bulle utilisateur.
|
||||
func TestQueueDuringGeneration(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Generating = true
|
||||
queued, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7)
|
||||
if err != nil || !queued {
|
||||
t.Fatalf("mise en file attendue, got queued=%v err=%v", queued, err)
|
||||
}
|
||||
// Réessai du même envoi (réponse perdue) : pas de doublon.
|
||||
if _, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7); err != ErrDupSend {
|
||||
t.Fatalf("doublon attendu, got %v", err)
|
||||
}
|
||||
msgs := c.drainQueued(c.epoch)
|
||||
if len(msgs) != 1 || msgs[0].Role != "user" {
|
||||
t.Fatalf("un message user attendu, got %#v", msgs)
|
||||
}
|
||||
if len(c.Log) != 1 || c.Log[0].Delta["user"] != "précision" {
|
||||
t.Fatalf("le message injecté doit apparaître dans le fil, journal : %#v", c.Log)
|
||||
}
|
||||
if again := c.drainQueued(c.epoch); len(again) != 0 {
|
||||
t.Fatal("la file doit être vidée par drainQueued")
|
||||
}
|
||||
}
|
||||
|
||||
// Une tâche planifiée qui occupe le modèle garde le refus : sa fin ne dépile rien.
|
||||
func TestQueueRefusedDuringTask(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Generating = true
|
||||
c.runningTaskName = "veille"
|
||||
if queued, _ := c.EnqueueOrStart("", "x", nil, Caps{}, 0.7); queued {
|
||||
t.Fatal("pas de file pendant une tâche planifiée")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop : la file est abandonnée et le client en est prévenu.
|
||||
func TestDropQueuedAnnounces(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Generating = true
|
||||
_, _ = c.EnqueueOrStart("", "a", nil, Caps{}, 0.7)
|
||||
c.dropQueued(c.epoch)
|
||||
if len(c.queued) != 0 || len(c.Log) != 1 || c.Log[0].Delta["queue_dropped"] != 1 {
|
||||
t.Fatalf("abandon non signalé : file=%d journal=%#v", len(c.queued), c.Log)
|
||||
}
|
||||
}
|
||||
|
||||
// Un client dont la discussion affichée n'est plus l'active reçoit d'abord un
|
||||
// reset, pour ne pas greffer le nouveau fil sur l'ancien.
|
||||
func TestSubscribeStaleConvResets(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = putStr(bkChat, ckActive, "c-nouvelle")
|
||||
c := newTestConv()
|
||||
for i := 0; i < 3; i++ {
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "q"})
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var first map[string]any
|
||||
c.SubscribeTail(ctx, 2, -1, "c-ancienne", func(m map[string]any) bool {
|
||||
if _, pad := m["pad"]; pad {
|
||||
return true
|
||||
}
|
||||
if first == nil {
|
||||
first = m
|
||||
}
|
||||
if _, ok := m["caught_up"]; ok {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if first["reset"] != true || first["id"] != "c-nouvelle" {
|
||||
t.Fatalf("reset attendu en premier, got %v", first)
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,14 @@ func recallIDToSeq(id string) (uint64, bool) {
|
||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||
encode, err := memEncoderNow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var id string
|
||||
err := withDB(func(d *bolt.DB) error {
|
||||
err = withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||
if err != nil {
|
||||
@@ -108,7 +114,15 @@ func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), raw)
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
|
||||
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
|
||||
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
|
||||
// refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), enc)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
@@ -131,7 +145,11 @@ func recallGet(id string) (recallBlock, bool) {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
found = json.Unmarshal(v, &blk) == nil
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : le bloc reste hors de portée
|
||||
}
|
||||
found = json.Unmarshal(dec, &blk) == nil
|
||||
return nil
|
||||
})
|
||||
return blk, found
|
||||
@@ -161,7 +179,11 @@ func recallSearch(query string, limit int) []recallBlock {
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var blk recallBlock
|
||||
if json.Unmarshal(v, &blk) != nil {
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : ce bloc ne participe pas
|
||||
}
|
||||
if json.Unmarshal(dec, &blk) != nil {
|
||||
return nil
|
||||
}
|
||||
label := strings.ToLower(blk.Label)
|
||||
|
||||
@@ -16,7 +16,6 @@ package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -118,6 +117,11 @@ var (
|
||||
)
|
||||
|
||||
func engineSeesImages() bool {
|
||||
// Preset externe : aucun /props à sonder, on s'en tient à la déclaration
|
||||
// du preset (case « le modèle accepte les images »).
|
||||
if externalActive() {
|
||||
return externalVisionActive()
|
||||
}
|
||||
visionProbeMu.Lock()
|
||||
defer visionProbeMu.Unlock()
|
||||
if time.Since(visionProbeAt) < 10*time.Second {
|
||||
@@ -213,11 +217,13 @@ func screenshotImageMessage(relPath string) (Message, bool) {
|
||||
if err != nil || len(b) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
// Redimensionnement sous maxImageDim avant l'envoi : une capture de page
|
||||
// fait facilement 1500×4000 px, dont le base64 pèse plus que ce que le
|
||||
// projecteur regarde (voir prepareImageForModel, web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
}},
|
||||
imageURLPart(b, mime),
|
||||
}}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package loki
|
||||
|
||||
// chat_scripts.go — le dossier de scripts DÉDIÉ et PROTÉGÉ de l'agent.
|
||||
//
|
||||
// La durabilité des scripts vient d'une séparation STRUCTURELLE, pas d'un verrou
|
||||
// sur les commandes : les scripts qu'on veut CONSERVER vivent dans scriptsDir() =
|
||||
// LOKI_HOME/scripts, à côté de memory, HORS du workspace jetable. Un nettoyage —
|
||||
// ou une catastrophe — dans le workspace ne peut plus les toucher. Le workspace
|
||||
// reste le bac à sable où l'IA peut tout casser sans risque (clones, tests,
|
||||
// fichiers temporaires).
|
||||
//
|
||||
// L'IA a par ailleurs la main libre sur le shell (suppression/déplacement/
|
||||
// renommage inclus) : aucun garde-fou « anti-rm » ne filtre les commandes. Le
|
||||
// SEUL accès réservé est le dossier memory, joignable uniquement par les outils
|
||||
// mem_* (voir guardToolOnly plus bas) — parce qu'il est chiffré, pas pour le
|
||||
// protéger d'une suppression.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scriptsPath résout un nom de script fourni par le modèle vers un chemin DANS
|
||||
// scriptsDir, en refusant toute évasion hors du dossier (« ../ », chemin absolu).
|
||||
// Renvoie une erreur plutôt qu'un chemin hors périmètre.
|
||||
func scriptsPath(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("nom de script vide")
|
||||
}
|
||||
// On interdit les chemins absolus et la remontée : un script vit dans
|
||||
// scriptsDir, point. Les sous-dossiers relatifs restent permis. Un nom qui
|
||||
// commence par un séparateur (« /etc/… ») est rejeté explicitement : sous
|
||||
// Windows il n'est pas « absolu » au sens de filepath mais n'a rien à faire là.
|
||||
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
|
||||
return "", fmt.Errorf("nom de script invalide (commence par un séparateur) : %s", name)
|
||||
}
|
||||
// Caractères hostiles au shell : le nom finit dans une ligne de commande
|
||||
// (scriptRunCommand l'entoure de guillemets) ; on refuse tout ce qui pourrait
|
||||
// en sortir. Le backslash est inclus : sous Windows c'est un séparateur (utiliser
|
||||
// « / » pour les sous-dossiers), sous Unix c'est un échappement shell. Refuser
|
||||
// partout rend le comportement identique sur les deux plateformes. Un nom de
|
||||
// fichier n'a de toute façon besoin d'aucun de ces caractères.
|
||||
if i := strings.IndexAny(name, "\"'`$;&|<>*?\\\n\r"); i >= 0 {
|
||||
return "", fmt.Errorf("nom de script invalide (caractère interdit %q) : %s", name[i:i+1], name)
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(name))
|
||||
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("nom de script invalide (pas de chemin absolu ni de ../) : %s", name)
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), clean)
|
||||
// Ceinture et bretelles : vérifie que le résultat est bien SOUS scriptsDir.
|
||||
if !underDir(full, scriptsDir()) {
|
||||
return "", fmt.Errorf("nom de script hors du dossier scripts : %s", name)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// scriptExists valide qu'un nom désigne bien un fichier script existant dans le
|
||||
// dossier (sans lire son contenu). Utilisé pour valider une tâche « script seul ».
|
||||
func scriptExists(name string) error {
|
||||
full, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return fmt.Errorf("script introuvable : %s", name)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return fmt.Errorf("%s est un dossier, pas un script", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listScripts renvoie les scripts présents (chemins relatifs à scriptsDir, avec
|
||||
// leur taille), triés. Ignore les dossiers.
|
||||
func listScripts() ([]scriptInfo, error) {
|
||||
root := scriptsDir()
|
||||
var out []scriptInfo
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // dossier absent ou illisible : on renvoie ce qu'on a
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
size := int64(0)
|
||||
if fi, ferr := d.Info(); ferr == nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
out = append(out, scriptInfo{Name: filepath.ToSlash(rel), Size: size})
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, err
|
||||
}
|
||||
|
||||
type scriptInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// toolOnlyDirs : dossiers auxquels l'IA n'a AUCUN accès direct (ni lecture, ni
|
||||
// écriture, ni listing) par bash/write/edit. Le seul chemin autorisé passe par
|
||||
// les outils dédiés — mem_* pour la mémoire. Ces outils écrivent en Go
|
||||
// directement (MemAdd…), ils ne passent PAS par fileWrite/runShell, donc ils ne
|
||||
// sont pas concernés par ces gardes.
|
||||
//
|
||||
// Le dossier scripts N'EST PAS ici : l'IA y écrit et y exécute ses scripts
|
||||
// librement (avec write/bash). Sa durabilité vient seulement de sa séparation
|
||||
// d'avec le workspace jetable, pas d'un verrou.
|
||||
func toolOnlyDirs() []string { return []string{memoryDir()} }
|
||||
|
||||
// toolOnlyLabel donne le nom des outils à utiliser à la place d'un accès direct
|
||||
// (seule la mémoire est concernée aujourd'hui, voir toolOnlyDirs).
|
||||
func toolOnlyLabel() string {
|
||||
return "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)"
|
||||
}
|
||||
|
||||
// guardToolOnlyPath refuse un accès write/edit à un chemin (déjà résolu) situé
|
||||
// dans un dossier « tool-only » (memory). Renvoie "" si le chemin est autorisé.
|
||||
func guardToolOnlyPath(path string) string {
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if underDir(path, d) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// guardToolOnlyCommand refuse une commande shell qui référence un dossier
|
||||
// « tool-only » (memory), lecture comprise (`cat`, `ls`, `>`…) : on bloque TOUTE
|
||||
// mention du dossier, pas seulement une suppression. Renvoie "" si autorisé.
|
||||
func guardToolOnlyCommand(command string) string {
|
||||
lc := strings.ToLower(command)
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if strings.Contains(lc, strings.ToLower(normPath(d))) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s via le shell. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// underDir indique si path est égal à dir ou situé dessous, après nettoyage et
|
||||
// (sous Windows) insensibilité à la casse.
|
||||
func underDir(path, dir string) bool {
|
||||
a := normPath(path)
|
||||
b := normPath(dir)
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(a, b+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// normPath normalise un chemin pour la comparaison : absolu si possible, nettoyé,
|
||||
// et minusculé sous Windows (système de fichiers insensible à la casse).
|
||||
func normPath(p string) string {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
if runtime.GOOS == "windows" {
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGuardToolOnly vérifie que memory est inaccessible en direct (shell/write/
|
||||
// edit) et n'autorise que les outils dédiés, tandis que scripts reste libre.
|
||||
func TestGuardToolOnly(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
// Shell : toute mention du dossier memory est refusée (lecture comprise). Le
|
||||
// dossier scripts, lui, est librement utilisable (l'IA y écrit et exécute).
|
||||
blockedCmds := []string{
|
||||
"cat " + memoryDir() + "/note.md",
|
||||
"echo x > " + memoryDir() + "/x",
|
||||
}
|
||||
for _, c := range blockedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg == "" {
|
||||
t.Errorf("commande aurait dû être refusée : %q", c)
|
||||
}
|
||||
}
|
||||
allowedCmds := []string{
|
||||
"ls " + workspaceDir(),
|
||||
"bash " + scriptsDir() + "/backup.sh", // exécuter un script : autorisé
|
||||
"ls " + scriptsDir(),
|
||||
}
|
||||
for _, c := range allowedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg != "" {
|
||||
t.Errorf("commande ne devait pas être bloquée : %q -> %s", c, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// write/edit : un chemin dans memory est refusé ; dans scripts ou workspace, non.
|
||||
if msg := guardToolOnlyPath(filepath.Join(memoryDir(), "a.md")); msg == "" {
|
||||
t.Errorf("écriture directe dans memory aurait dû être refusée")
|
||||
}
|
||||
for _, p := range []string{filepath.Join(scriptsDir(), "b.sh"), filepath.Join(workspaceDir(), "ok.txt")} {
|
||||
if msg := guardToolOnlyPath(p); msg != "" {
|
||||
t.Errorf("écriture ne devait pas être bloquée : %q -> %s", p, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptsPathEscape vérifie qu'on ne peut pas sortir du dossier scripts ni
|
||||
// glisser un caractère hostile au shell dans le nom.
|
||||
func TestScriptsPathEscape(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
bad := []string{"../evil.sh", "..", "/etc/passwd", `..\..\x`, `a";rm -rf ~;".sh`, "a$b.sh", "a`b`.sh"}
|
||||
for _, name := range bad {
|
||||
if _, err := scriptsPath(name); err == nil {
|
||||
t.Errorf("aurait dû refuser le nom : %q", name)
|
||||
}
|
||||
}
|
||||
good := []string{"backup.ps1", "sub/backup.sh", "a.sh", "my backup.sh"}
|
||||
for _, name := range good {
|
||||
p, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
t.Errorf("aurait dû accepter %q : %v", name, err)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(normPath(p), normPath(scriptsDir())) {
|
||||
t.Errorf("%q résolu hors de scriptsDir : %s", name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptExists couvre la validation d'existence utilisée par les tâches script.
|
||||
func TestScriptExists(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
if err := scriptExists("hello.sh"); err == nil {
|
||||
t.Fatalf("un script absent aurait dû échouer")
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), "hello.sh")
|
||||
if err := os.MkdirAll(scriptsDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("hello.sh"); err != nil {
|
||||
t.Fatalf("scriptExists : %v", err)
|
||||
}
|
||||
list, _ := listScripts()
|
||||
if len(list) != 1 || list[0].Name != "hello.sh" {
|
||||
t.Fatalf("list inattendu : %+v", list)
|
||||
}
|
||||
// Un dossier n'est pas un script.
|
||||
if err := os.MkdirAll(filepath.Join(scriptsDir(), "d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("d"); err == nil {
|
||||
t.Fatalf("un dossier ne doit pas passer pour un script")
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,8 @@ func convIndexForProject(slug string) []convMeta {
|
||||
// tagOrphanConversations rattache au projet donné les discussions qui n'en ont
|
||||
// pas encore (migration, une seule fois : ensuite il n'y a plus d'orphelines).
|
||||
func tagOrphanConversations(slug string) {
|
||||
convIndexMu.Lock()
|
||||
defer convIndexMu.Unlock()
|
||||
idx := convIndex()
|
||||
changed := false
|
||||
for i := range idx {
|
||||
@@ -101,7 +103,10 @@ func tagOrphanConversations(slug string) {
|
||||
|
||||
func convIndex() []convMeta {
|
||||
var idx []convMeta
|
||||
if b := getBytes(bkChat, ckIndex); len(b) > 0 {
|
||||
// L'index porte les TITRES des discussions : chiffré comme leur contenu
|
||||
// quand la mémoire l'est. Verrouillé, il se lit vide — la liste est masquée,
|
||||
// rien n'est perdu.
|
||||
if b, ok := getStoreBytes(bkChat, ckIndex); ok && len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &idx)
|
||||
}
|
||||
// Plus récemment modifiée en tête : c'est l'ordre attendu d'une liste de
|
||||
@@ -115,24 +120,42 @@ func convIndexSave(idx []convMeta) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, ckIndex, b)
|
||||
_ = putStoreBytes(bkChat, ckIndex, b)
|
||||
}
|
||||
|
||||
// convEnsureActive garantit qu'une discussion active existe, et reprend le fil
|
||||
// unique de l'amont s'il y en a un (migration silencieuse, une seule fois).
|
||||
//
|
||||
// L'identifiant est gardé en RAM (chat_persist.go) : seule la première lecture
|
||||
// ouvre la base. Le test, la lecture et la création se font sous convActiveMu —
|
||||
// deux appelants simultanés sur une base neuve forgeaient sinon chacun leur
|
||||
// identifiant, et le second rendait orpheline la discussion du premier.
|
||||
func convEnsureActive() string {
|
||||
if id := convActiveCached(); id != "" {
|
||||
return id
|
||||
}
|
||||
convActiveMu.Lock()
|
||||
defer convActiveMu.Unlock()
|
||||
if id := convActiveCached(); id != "" {
|
||||
return id
|
||||
}
|
||||
if id := getStr(bkChat, ckActive); id != "" {
|
||||
convActiveRemember(id)
|
||||
return id
|
||||
}
|
||||
id := newConvID()
|
||||
if legacy := getBytes(bkChat, ckLegacy); len(legacy) > 0 {
|
||||
// Migration : le fil unique devient la première discussion. On garde la
|
||||
// clé d'origine intacte — en cas de retour arrière, rien n'est perdu.
|
||||
_ = putBytes(bkChat, convKey(id), legacy)
|
||||
_ = putStoreBytes(bkChat, convKey(id), legacy)
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
now := time.Now().Unix()
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
|
||||
project := activeProjectSlug()
|
||||
convIndexMu.Lock()
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: project}))
|
||||
convIndexMu.Unlock()
|
||||
convActiveRemember(id)
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -140,33 +163,12 @@ func convEnsureActive() string {
|
||||
// dépendance à un générateur aléatoire.
|
||||
func newConvID() string { return fmt.Sprintf("c%d", time.Now().UnixNano()) }
|
||||
|
||||
// convTouchMeta rafraîchit les métadonnées de la discussion active après un
|
||||
// enregistrement : titre (déduit du premier message si l'utilisateur n'en a pas
|
||||
// choisi), date de modification et nombre d'échanges.
|
||||
func convTouchMeta(id string, title string, turns int) {
|
||||
idx := convIndex()
|
||||
now := time.Now().Unix()
|
||||
for i := range idx {
|
||||
if idx[i].ID != id {
|
||||
continue
|
||||
}
|
||||
idx[i].Updated = now
|
||||
idx[i].Turns = turns
|
||||
if idx[i].Title == "" {
|
||||
idx[i].Title = title
|
||||
}
|
||||
convIndexSave(idx)
|
||||
return
|
||||
}
|
||||
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns, Project: activeProjectSlug()}))
|
||||
}
|
||||
|
||||
// convSummary lit le premier message utilisateur pour en faire un titre. Sans
|
||||
// message, on laisse vide : l'UI affiche « Nouvelle discussion » et le titre se
|
||||
// posera tout seul au premier échange.
|
||||
func convSummary(msgs []Message) string {
|
||||
for _, m := range msgs {
|
||||
if m.Role != "user" {
|
||||
if m.Role != "user" || isLokiInjected(m) {
|
||||
continue
|
||||
}
|
||||
s, _ := m.Content.(string)
|
||||
@@ -214,8 +216,7 @@ func projectSwitch(slug string) error {
|
||||
return nil
|
||||
}
|
||||
// convIndex rend la plus récemment modifiée en tête.
|
||||
_ = putStr(bkChat, ckActive, list[0].ID)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(list[0].ID)))
|
||||
convActivate(list[0].ID, storeBytes(bkChat, convKey(list[0].ID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -238,12 +239,11 @@ func convSwitch(id string) error {
|
||||
if !found {
|
||||
return fmt.Errorf("discussion introuvable")
|
||||
}
|
||||
if id == getStr(bkChat, ckActive) {
|
||||
if id == convEnsureActive() {
|
||||
return nil
|
||||
}
|
||||
conv.persist() // fige la discussion qu'on quitte
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(id)))
|
||||
convActivate(id, storeBytes(bkChat, convKey(id)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -253,9 +253,11 @@ func convSwitch(id string) error {
|
||||
func convCreate() string {
|
||||
id := newConvID()
|
||||
now := time.Now().Unix()
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(nil)
|
||||
project := activeProjectSlug()
|
||||
convIndexMu.Lock()
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: project}))
|
||||
convIndexMu.Unlock()
|
||||
convActivate(id, nil)
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -274,6 +276,8 @@ func convRename(id, title string) error {
|
||||
if id == "" || title == "" {
|
||||
return fmt.Errorf("identifiant ou titre manquant")
|
||||
}
|
||||
convIndexMu.Lock()
|
||||
defer convIndexMu.Unlock()
|
||||
idx := convIndex()
|
||||
for i := range idx {
|
||||
if idx[i].ID == id {
|
||||
@@ -291,6 +295,7 @@ func convRename(id, title string) error {
|
||||
func convDelete(id string) error {
|
||||
convOpMu.Lock()
|
||||
defer convOpMu.Unlock()
|
||||
convIndexMu.Lock()
|
||||
idx := convIndex()
|
||||
next := make([]convMeta, 0, len(idx))
|
||||
found := false
|
||||
@@ -301,10 +306,24 @@ func convDelete(id string) error {
|
||||
}
|
||||
next = append(next, m)
|
||||
}
|
||||
convIndexMu.Unlock()
|
||||
if !found {
|
||||
return fmt.Errorf("discussion introuvable")
|
||||
}
|
||||
// Écritures différées (chat_persist.go) : on écarte d'abord tout ce qui
|
||||
// viserait encore cette discussion, puis on attend l'écriture déjà en vol —
|
||||
// sinon elle la ferait renaître, contenu et entrée d'index, juste après.
|
||||
persistQ.forget(id)
|
||||
persistQ.flush()
|
||||
convIndexMu.Lock()
|
||||
next = next[:0]
|
||||
for _, m := range convIndex() {
|
||||
if m.ID != id {
|
||||
next = append(next, m)
|
||||
}
|
||||
}
|
||||
convIndexSave(next)
|
||||
convIndexMu.Unlock()
|
||||
_ = putBytes(bkChat, convKey(id), nil)
|
||||
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
|
||||
// elle, et ses critères/mode/puce partent avec ses messages.
|
||||
@@ -315,14 +334,14 @@ func convDelete(id string) error {
|
||||
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
|
||||
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
|
||||
dropConvFiles(id)
|
||||
if id != getStr(bkChat, ckActive) {
|
||||
deleteToolResultsFor(id) // ses résultats « voir plus » partent avec elle
|
||||
if id != convEnsureActive() {
|
||||
return nil
|
||||
}
|
||||
if len(next) == 0 {
|
||||
convCreate() // surtout pas convNew : il réenregistrerait la supprimée
|
||||
return nil
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, next[0].ID)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(next[0].ID)))
|
||||
convActivate(next[0].ID, storeBytes(bkChat, convKey(next[0].ID)))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package loki
|
||||
|
||||
// chat_shell_hygiene.go — ce que le terminal rend au modèle, nettoyé (repris de
|
||||
// shell.ts / shell-tail.ts d'OpenFox, 2.0.160) :
|
||||
//
|
||||
// - « cmd | tail -N » : le code de sortie devenait celui de tail (0), et un
|
||||
// build en échec passait pour réussi. On retire le tail de la commande et
|
||||
// on garde nous-mêmes les N dernières lignes : même sortie, vrai code.
|
||||
// - séquences ANSI (couleurs, curseur) : du bruit en tokens, illisible ;
|
||||
// - « cmd & » en mode Code : le process orphelin n'a ni sortie ni arrêt —
|
||||
// bash_bg est fait pour ça.
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// tailPipeRe : « | tail », « | tail -20 », « | tail -n 20 », « | tail -n20 » en
|
||||
// toute fin de commande.
|
||||
var tailPipeRe = regexp.MustCompile(`\s*\|\s*tail(?:\s+-n\s*(\d+)|\s+-(\d+))?\s*$`)
|
||||
|
||||
// splitTailPipe retire un « | tail -N » final. Renvoie la commande sans lui et
|
||||
// N (0 = pas de tail ; 10 = tail sans nombre, sa valeur par défaut).
|
||||
func splitTailPipe(command string) (string, int) {
|
||||
m := tailPipeRe.FindStringSubmatchIndex(command)
|
||||
if m == nil {
|
||||
return command, 0
|
||||
}
|
||||
n := 10
|
||||
for _, g := range []int{2, 4} {
|
||||
if m[g] >= 0 {
|
||||
if v, err := strconv.Atoi(command[m[g]:m[g+1]]); err == nil && v > 0 {
|
||||
n = v
|
||||
}
|
||||
}
|
||||
}
|
||||
rest := strings.TrimSpace(command[:m[0]])
|
||||
if rest == "" {
|
||||
return command, 0
|
||||
}
|
||||
return rest, n
|
||||
}
|
||||
|
||||
// shellANSIRe : séquences CSI (couleurs, curseur) et OSC (titres de fenêtre).
|
||||
var shellANSIRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`)
|
||||
|
||||
func stripANSI(s string) string {
|
||||
if !strings.Contains(s, "\x1b") {
|
||||
return s
|
||||
}
|
||||
return shellANSIRe.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// trailingBackground : la commande se termine par un « & » de mise en
|
||||
// arrière-plan (pas « && »).
|
||||
func trailingBackground(command string) bool {
|
||||
c := strings.TrimSpace(command)
|
||||
return strings.HasSuffix(c, "&") && !strings.HasSuffix(c, "&&")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitTailPipe(t *testing.T) {
|
||||
cas := map[string]struct {
|
||||
cmd string
|
||||
n int
|
||||
}{
|
||||
"go build ./... 2>&1 | tail -20": {"go build ./... 2>&1", 20},
|
||||
"make | tail -n 5": {"make", 5},
|
||||
"make|tail -n40": {"make", 40},
|
||||
"go test | tail": {"go test", 10},
|
||||
"tail -f log.txt": {"tail -f log.txt", 0},
|
||||
"cat a | tail -5 | grep x": {"cat a | tail -5 | grep x", 0},
|
||||
"echo ok": {"echo ok", 0},
|
||||
}
|
||||
for in, want := range cas {
|
||||
if cmd, n := splitTailPipe(in); cmd != want.cmd || n != want.n {
|
||||
t.Errorf("%q → (%q, %d), attendu (%q, %d)", in, cmd, n, want.cmd, want.n)
|
||||
}
|
||||
}
|
||||
if !trailingBackground("./serveur &") || trailingBackground("make && make test") || trailingBackground("ls 2>&1") {
|
||||
t.Error("trailingBackground se trompe")
|
||||
}
|
||||
if got := stripANSI("\x1b[1;31mFAIL\x1b[0m ok"); got != "FAIL ok" {
|
||||
t.Errorf("stripANSI = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// « cmd | tail -N » rend le VRAI code de sortie, et seulement N lignes.
|
||||
func TestShellTailGardeLeCodeDeSortie(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell POSIX")
|
||||
}
|
||||
withWorkspace(t)
|
||||
out := runShell(context.Background(), "sh -c 'printf \"l1\nl2\nl3\n\"; exit 2' | tail -n 2", 10)
|
||||
if !strings.HasPrefix(out, "exit: 2") || strings.Contains(out, "l1") || !strings.Contains(out, "l3") {
|
||||
t.Fatalf("sortie = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Au délai dépassé, la sortie déjà produite est rendue.
|
||||
func TestShellTimeoutGardeLaSortie(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell POSIX")
|
||||
}
|
||||
withWorkspace(t)
|
||||
out := runShell(context.Background(), "echo avant; sleep 5", 1)
|
||||
if !strings.Contains(out, "timeout") || !strings.Contains(out, "avant") {
|
||||
t.Fatalf("sortie = %q", out)
|
||||
}
|
||||
}
|
||||
+82
-12
@@ -26,7 +26,10 @@ const (
|
||||
// ("Wait, let me check… Wait, I'll just run it…") and commits to an action.
|
||||
// The per-tool "Outil disponible" sections live in machine/skills prompts so
|
||||
// they only appear when the matching feature is on.
|
||||
func baseSystemPrompt(caps Caps) string {
|
||||
//
|
||||
// tools = les outils annoncés sur ce même tour (EnabledTools) : ce qui en est
|
||||
// dit ici (la ligne MCP) en est déduit, jamais relu ailleurs.
|
||||
func baseSystemPrompt(caps Caps, tools []Tool) string {
|
||||
hasMem := caps.Mem != MemOff
|
||||
// No tool access at all → no agentic preamble. A plain chat model told to
|
||||
// "call tools immediately" hallucinates textual tool calls (e.g.
|
||||
@@ -56,7 +59,7 @@ func baseSystemPrompt(caps Caps) string {
|
||||
b.WriteString("\n\n" + rp)
|
||||
}
|
||||
}
|
||||
if caps.Mem == MemAlways {
|
||||
if memProactive(caps.Mem) {
|
||||
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
|
||||
}
|
||||
// PAS de catalogue d'outils ici : leurs schémas, envoyés dans la même requête,
|
||||
@@ -76,7 +79,15 @@ func baseSystemPrompt(caps Caps) string {
|
||||
case MemAlways:
|
||||
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
|
||||
b.WriteString("- 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")
|
||||
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes.\n")
|
||||
// Mode INJECTÉ : l'index est en tête de contexte (mem_index.go), la
|
||||
// recherche devient facultative — l'IA lit directement la bonne page.
|
||||
b.WriteString("- 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")
|
||||
case MemSearchFirst:
|
||||
// Mode RECHERCHE : rien d'injecté, prompt léger — mais l'IA DOIT chercher
|
||||
// elle-même, sinon elle ignore ce que sa mémoire contient.
|
||||
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
|
||||
b.WriteString("- 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")
|
||||
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes. Nothing is preloaded: a search is the only way to know what you already know.\n")
|
||||
case MemOnDemand:
|
||||
b.WriteString("\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.\n")
|
||||
}
|
||||
@@ -89,7 +100,14 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale
|
||||
// à connaître pour le modèle — juste [texte](chemin).
|
||||
b.WriteString("To 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.\n")
|
||||
if caps.Mem == MemAlways {
|
||||
// Auto-planification : les schémas task_* partent déjà dans la requête, donc
|
||||
// on n'y répète NI leur usage NI leurs arguments — seulement QUAND s'en
|
||||
// servir (sinon le modèle ne pense jamais à se planifier quoi que ce soit)
|
||||
// et le fait qu'un script planifié tourne sans modèle, que les schémas ne
|
||||
// disent pas. Une ligne, pas trois : le budget du préambule est compté
|
||||
// (TestSystemPromptStaysLean).
|
||||
b.WriteString("You 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")
|
||||
if caps.Mem == MemSearchFirst {
|
||||
b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
|
||||
}
|
||||
}
|
||||
@@ -101,10 +119,13 @@ func baseSystemPrompt(caps Caps) string {
|
||||
b.WriteString("Your 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.\n")
|
||||
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — 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")
|
||||
}
|
||||
if caps.Agent && caps.ComputerUse {
|
||||
b.WriteString(cuPromptLine())
|
||||
}
|
||||
// Ligne MCP tirée des outils de CE tour (voir mcpPromptLine) : un rôle sans
|
||||
// outil MCP n'en reçoit pas, et le premier tour après un démarrage l'a déjà.
|
||||
if caps.Agent {
|
||||
if line := mcpPromptLine(); line != "" {
|
||||
b.WriteString(line)
|
||||
}
|
||||
b.WriteString(mcpPromptLine(tools))
|
||||
}
|
||||
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
|
||||
return b.String()
|
||||
@@ -177,7 +198,7 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
|
||||
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
|
||||
// aux « installations » en /usr/local/bin qui échouent faute de root.
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. 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 when the user explicitly named that location.")
|
||||
b.WriteString(" 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 " + scriptsDir() + " 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.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -192,12 +213,24 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du
|
||||
// délai (5 minutes au maximum), bouton stop sans effet.
|
||||
func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// Accès réservé aux outils : le dossier mémoire n'est JAMAIS touché au shell
|
||||
// (ni lu, ni écrit, ni listé) — uniquement via les outils mem_*, qui savent
|
||||
// tenir l'index MEMORY.md à jour. Un `cat memory/…` contournait l'index et
|
||||
// laissait le modèle croire qu'il avait lu une page que la mémoire, elle,
|
||||
// n'avait pas servie.
|
||||
if msg := guardToolOnlyCommand(command); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = toolDefaultTimeout
|
||||
}
|
||||
if timeoutSec > toolMaxTimeout {
|
||||
timeoutSec = toolMaxTimeout
|
||||
}
|
||||
// « … | tail -N » final : retiré, on garde nous-mêmes les N dernières lignes
|
||||
// (chat_shell_hygiene.go) — sinon le code de sortie est celui de tail.
|
||||
command, keepLines := splitTailPipe(command)
|
||||
start := time.Now()
|
||||
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
cmd := newShellCmd(ctx, command)
|
||||
@@ -228,9 +261,27 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// issue connue était de redémarrer loki-ui.
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
err := cmd.Run()
|
||||
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
|
||||
// demandait un tail.
|
||||
clean := func(s string) string { return tailOutput(stripANSI(s)) }
|
||||
out := stdout.String()
|
||||
if keepLines > 0 {
|
||||
out = tailLines(out, keepLines)
|
||||
}
|
||||
out = clean(out)
|
||||
errOut := clean(stderr.String())
|
||||
switch {
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
return fmt.Sprintf("[timeout après %ds]", timeoutSec)
|
||||
// La sortie déjà produite reste utile (où le build en était, quel test
|
||||
// bloquait) : on la rend au lieu d'un « timeout » sec (OpenFox).
|
||||
msg := fmt.Sprintf("[timeout après %ds — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]", timeoutSec)
|
||||
if out != "" {
|
||||
msg += "\n\nstdout (partiel):\n" + out
|
||||
}
|
||||
if errOut != "" {
|
||||
msg += "\n\nstderr (partiel):\n" + errOut
|
||||
}
|
||||
return msg
|
||||
case errors.Is(parent.Err(), context.Canceled):
|
||||
return "[commande interrompue]"
|
||||
}
|
||||
@@ -242,9 +293,8 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
return fmt.Sprintf("[erreur: %v]", err)
|
||||
}
|
||||
}
|
||||
out := tailRunes(stdout.String(), toolMaxOutput)
|
||||
errOut := tailRunes(stderr.String(), toolMaxOutput)
|
||||
parts := []string{fmt.Sprintf("exit: %d", exit)}
|
||||
// « exit: N » en tête : tasks_script.go s'y fie. La durée suit.
|
||||
parts := []string{fmt.Sprintf("exit: %d · %s", exit, time.Since(start).Round(100*time.Millisecond))}
|
||||
if out != "" {
|
||||
parts = append(parts, "stdout:\n"+out)
|
||||
}
|
||||
@@ -274,6 +324,10 @@ func fileWrite(path, content string) string {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
@@ -312,6 +366,10 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return "[erreur] old vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
@@ -357,6 +415,18 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
|
||||
}
|
||||
|
||||
// tailOutput garde la FIN d'une sortie de commande (toolMaxOutput runes) et dit
|
||||
// explicitement ce qui a été coupé (AJEAN 0.15.5). La coupe était silencieuse :
|
||||
// le modèle croyait voir toute la sortie, et la bulle affichait toujours
|
||||
// ~2004 tok sans qu'on sache pourquoi.
|
||||
func tailOutput(s string) string {
|
||||
n := len([]rune(s))
|
||||
if n <= toolMaxOutput {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("[sortie tronquée : %d caractères au total, seuls les %d derniers sont gardés]\n", n, toolMaxOutput) + tailRunes(s, toolMaxOutput)
|
||||
}
|
||||
|
||||
// tailRunes returns the last n runes of s (used to cap tool output).
|
||||
func tailRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
|
||||
@@ -80,7 +80,17 @@ func toolSeeImage(path string) (string, map[string]any) {
|
||||
if len(b) == 0 {
|
||||
return "[erreur] fichier vide : " + path, nil
|
||||
}
|
||||
return "[ok] image chargée : " + filepath.Base(abs), map[string]any{
|
||||
// Même préparation que les pièces jointes : orientation EXIF redressée et
|
||||
// grand côté ramené sous maxImageDim (voir web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return "[ok] image chargée : " + filepath.Base(abs), imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// imageURLPart construit la partie multimodale {type:image_url, image_url:{url}}
|
||||
// à partir d'octets image déjà préparés — le SEUL format qu'un llama-server
|
||||
// --mmproj comprend. Partagé par see_image, web_screenshot et browser_screenshot.
|
||||
func imageURLPart(b []byte, mime string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
@@ -93,8 +103,14 @@ func toolSeeImage(path string) (string, map[string]any) {
|
||||
// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne
|
||||
// dirait pas LAQUELLE rouvrir.
|
||||
func seeImageMessage(label string, img map[string]any) Message {
|
||||
// Sans parenthèses vides : browser_screenshot n'a pas d'argument (label=""),
|
||||
// là où see_image porte le chemin du fichier.
|
||||
text := "Image :"
|
||||
if label != "" {
|
||||
text = "Image demandée (" + label + ") :"
|
||||
}
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Image demandée (" + label + ") :"},
|
||||
{"type": "text", "text": text},
|
||||
img,
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package loki
|
||||
|
||||
// code_alias.go — réparation des noms d'outils et d'arguments hallucinés.
|
||||
//
|
||||
// Les petits modèles ont appris d'autres agents : ils appellent read_file,
|
||||
// str_replace, run_command, ou passent path / old_string au lieu de file /
|
||||
// old. L'appel tombait sur « outil inconnu » ou sur un argument manquant, et
|
||||
// le tour se perdait en allers-retours. On traduit vers l'outil RÉEL quand la
|
||||
// cible existe dans ce tour (généralisation du transformSubAgentAliases
|
||||
// d'OpenFox : un outil nommé d'après un sous-agent devient subagent{role}).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// toolNameAliases : nom halluciné → outil de Loki.
|
||||
var toolNameAliases = map[string]string{
|
||||
"read_file": "read", "view": "read", "cat": "read", "open_file": "read",
|
||||
"write_file": "write", "create_file": "write",
|
||||
"edit_file": "edit", "str_replace": "edit", "replace_in_file": "edit", "str_replace_editor": "edit",
|
||||
"run_command": "bash", "shell": "bash", "run_shell": "bash", "execute_command": "bash", "terminal": "bash",
|
||||
"search": "grep", "grep_search": "grep", "search_files": "grep",
|
||||
"find_files": "glob", "list_files": "glob", "file_search": "glob",
|
||||
}
|
||||
|
||||
// toolArgAliases : par outil, argument halluciné → argument attendu.
|
||||
var toolArgAliases = map[string]map[string]string{
|
||||
"read": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file"},
|
||||
"write": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file", "contents": "content", "text": "content"},
|
||||
"edit": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file",
|
||||
"old_string": "old", "old_str": "old", "old_text": "old", "search": "old",
|
||||
"new_string": "new", "new_str": "new", "new_text": "new", "replace": "new"},
|
||||
"bash": {"cmd": "command"},
|
||||
"bash_bg": {"cmd": "command"},
|
||||
"grep": {"query": "pattern", "regex": "pattern"},
|
||||
"glob": {"query": "pattern"},
|
||||
}
|
||||
|
||||
// repairToolCall corrige en place le nom et les arguments d'un appel quand ils
|
||||
// visent, sous un autre nom, un outil disponible dans ce tour. Renvoie true si
|
||||
// l'appel a été modifié. Les arguments doivent déjà être du JSON valide.
|
||||
func repairToolCall(tc *ToolCall, tools []Tool) bool {
|
||||
have := make(map[string]bool, len(tools))
|
||||
for _, t := range tools {
|
||||
have[t.Function.Name] = true
|
||||
}
|
||||
name := tc.Function.Name
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil || args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
changed := false
|
||||
if !have[name] {
|
||||
lower := strings.ToLower(strings.TrimSpace(name))
|
||||
if t := toolNameAliases[lower]; t != "" && have[t] {
|
||||
name, changed = t, true
|
||||
} else if have["subagent"] {
|
||||
// Un sous-agent appelé comme un outil (« explorer », « code_reviewer »).
|
||||
role := strings.ReplaceAll(lower, "_", "-")
|
||||
for _, r := range subagentRoles {
|
||||
if role == r {
|
||||
if _, ok := args["role"]; !ok {
|
||||
args["role"] = r
|
||||
}
|
||||
if _, ok := args["task"]; !ok {
|
||||
for _, k := range []string{"prompt", "query", "question", "description"} {
|
||||
if v, ok := args[k].(string); ok && v != "" {
|
||||
args["task"] = v
|
||||
delete(args, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
name, changed = "subagent", true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for from, to := range toolArgAliases[name] {
|
||||
v, ok := args[from]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := args[to]; !exists {
|
||||
args[to] = v
|
||||
changed = true
|
||||
}
|
||||
delete(args, from)
|
||||
}
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
b, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
tc.Function.Name = name
|
||||
tc.Function.Arguments = string(b)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func outils(noms ...string) []Tool {
|
||||
var ts []Tool
|
||||
for _, n := range noms {
|
||||
ts = append(ts, Tool{Type: "function", Function: ToolFunction{Name: n}})
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// Noms et arguments appris d'autres agents : traduits vers les outils de Loki.
|
||||
func TestReparationDesAppelsHallucines(t *testing.T) {
|
||||
tools := outils("read", "edit", "bash", "subagent")
|
||||
cas := []struct {
|
||||
name, args, wantName string
|
||||
wantArgs map[string]any
|
||||
}{
|
||||
{"read_file", `{"path":"a.go"}`, "read", map[string]any{"file": "a.go"}},
|
||||
{"str_replace", `{"file_path":"a.go","old_string":"x","new_string":"y"}`, "edit", map[string]any{"file": "a.go", "old": "x", "new": "y"}},
|
||||
{"run_command", `{"cmd":"ls"}`, "bash", map[string]any{"command": "ls"}},
|
||||
{"edit", `{"file":"a.go","old_str":"x","new":"y"}`, "edit", map[string]any{"file": "a.go", "old": "x", "new": "y"}},
|
||||
{"code_reviewer", `{"prompt":"relis"}`, "subagent", map[string]any{"role": "code-reviewer", "task": "relis"}},
|
||||
}
|
||||
for _, c := range cas {
|
||||
tc := ToolCall{Function: ToolCallFunc{Name: c.name, Arguments: c.args}}
|
||||
if !repairToolCall(&tc, tools) {
|
||||
t.Errorf("%s : non réparé", c.name)
|
||||
continue
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &got)
|
||||
if tc.Function.Name != c.wantName || len(got) != len(c.wantArgs) {
|
||||
t.Errorf("%s → %s %v", c.name, tc.Function.Name, got)
|
||||
continue
|
||||
}
|
||||
for k, v := range c.wantArgs {
|
||||
if got[k] != v {
|
||||
t.Errorf("%s : %s=%v, attendu %v", c.name, k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Outil cible absent de ce tour, ou appel déjà correct : intact.
|
||||
for _, tc := range []ToolCall{
|
||||
{Function: ToolCallFunc{Name: "write_file", Arguments: `{"path":"a"}`}},
|
||||
{Function: ToolCallFunc{Name: "read", Arguments: `{"file":"a"}`}},
|
||||
} {
|
||||
if repairToolCall(&tc, tools) {
|
||||
t.Errorf("modifié à tort : %+v", tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ package loki
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
type Criterion struct {
|
||||
ID int `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"` // pending | passed | failed
|
||||
Status string `json:"status"` // pending | completed (le builder dit « fait ») | passed | failed
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ func critKey(convID string) string { return "crit:" + convID }
|
||||
|
||||
func critList(convID string) []Criterion {
|
||||
var list []Criterion
|
||||
if b := getBytes(bkChat, critKey(convID)); len(b) > 0 {
|
||||
if b, ok := getStoreBytes(bkChat, critKey(convID)); ok && len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &list)
|
||||
}
|
||||
return list
|
||||
@@ -45,7 +46,7 @@ func critSave(convID string, list []Criterion) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, critKey(convID), b)
|
||||
_ = putStoreBytes(bkChat, critKey(convID), b)
|
||||
}
|
||||
|
||||
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
|
||||
@@ -74,6 +75,18 @@ func critPending(list []Criterion) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// critOpen : les critères que le builder n'a pas encore déclarés faits —
|
||||
// pending, ou failed et pas encore repris (la reprise les remet à completed).
|
||||
func critOpen(list []Criterion) []Criterion {
|
||||
var out []Criterion
|
||||
for _, c := range list {
|
||||
if c.Status == "pending" || c.Status == "failed" || c.Status == "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// critRender : la liste formatée pour un prompt (builder ou verifier).
|
||||
func critRender(list []Criterion) string {
|
||||
var b strings.Builder
|
||||
@@ -84,6 +97,8 @@ func critRender(list []Criterion) string {
|
||||
mark = "[✓]"
|
||||
case "failed":
|
||||
mark = "[✗]"
|
||||
case "completed":
|
||||
mark = "[~]" // fait selon le builder, pas encore vérifié
|
||||
}
|
||||
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
|
||||
if c.Note != "" {
|
||||
@@ -100,15 +115,15 @@ func criteriaTool() Tool {
|
||||
Function: ToolFunction{
|
||||
Name: "criteria",
|
||||
Description: "Manage the acceptance criteria of the current task (the contract that defines DONE). " +
|
||||
"action=add posits new criteria (texts[]), action=set updates one (id + status passed|failed|pending, optional note), " +
|
||||
"action=list shows them, action=clear removes them all. Set criteria BEFORE building; only the verification pass may mark passed.",
|
||||
"action=add posits new criteria (texts[]), action=set updates one (id + status, optional note), " +
|
||||
"action=list shows them, action=clear removes them all. Set criteria BEFORE building. Builder: mark each criterion completed once you have done AND checked it — verification only starts when nothing is left pending. Only the verification pass may mark passed or failed.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
|
||||
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
|
||||
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
|
||||
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
|
||||
"status": map[string]any{"type": "string", "enum": []string{"pending", "completed", "passed", "failed"}},
|
||||
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
@@ -158,8 +173,15 @@ func toolCriteria(args map[string]any, allowPass bool) string {
|
||||
critNotify(list)
|
||||
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
|
||||
case "set":
|
||||
idf, _ := args["id"].(float64)
|
||||
id := int(idf)
|
||||
// id numérique ou chaîne (« "2" », « "#2" ») : les petits modèles
|
||||
// écrivent volontiers l'id entre guillemets, qui tombait sinon à #0.
|
||||
id := 0
|
||||
switch v := args["id"].(type) {
|
||||
case float64:
|
||||
id = int(v)
|
||||
case string:
|
||||
id, _ = strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(v), "#"))
|
||||
}
|
||||
status, _ := args["status"].(string)
|
||||
note, _ := args["note"].(string)
|
||||
if status == "passed" && !allowPass {
|
||||
|
||||
@@ -25,8 +25,10 @@ func gitStatusTool() Tool {
|
||||
Name: "git_status",
|
||||
Description: "git status of the working folder (branch + changed files, porcelain format).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -42,6 +44,7 @@ func gitDiffTool() Tool {
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Limit the diff to this path"},
|
||||
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -66,15 +69,56 @@ func gitCloneTool() Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// gitRepoDir : où lancer git_status / git_diff. La discussion n'est souvent PAS
|
||||
// un dépôt : git_clone range le dépôt dans un sous-dossier, et git, lancé à la
|
||||
// racine, échouait (« not a git repository ») — alors que le vérificateur a
|
||||
// pour consigne de commencer par git_diff. Ordre : le sous-dossier demandé
|
||||
// (dir), la racine si c'est un dépôt, sinon l'unique sous-dossier qui en est
|
||||
// un. Plusieurs candidats : on demande de choisir.
|
||||
func gitRepoDir(dir string) (string, error) {
|
||||
base := agentCwd()
|
||||
if dir = strings.TrimSpace(dir); dir != "" {
|
||||
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
|
||||
return "", fmt.Errorf("dossier invalide : %s", dir)
|
||||
}
|
||||
return filepath.Join(base, dir), nil
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, ".git")); err == nil {
|
||||
return base, nil
|
||||
}
|
||||
entries, _ := os.ReadDir(base)
|
||||
var repos []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, e.Name(), ".git")); err == nil {
|
||||
repos = append(repos, e.Name())
|
||||
}
|
||||
}
|
||||
switch len(repos) {
|
||||
case 0:
|
||||
return base, nil // git dira lui-même « not a git repository »
|
||||
case 1:
|
||||
return filepath.Join(base, repos[0]), nil
|
||||
}
|
||||
return "", fmt.Errorf("plusieurs dépôts ici (%s) : précise dir", strings.Join(repos, ", "))
|
||||
}
|
||||
|
||||
// runGit exécute git dans le dossier de la discussion, sortie bornée.
|
||||
func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
|
||||
return runGitIn(ctx, agentCwd(), timeout, args...)
|
||||
}
|
||||
|
||||
// runGitIn exécute git dans dir, sortie bornée.
|
||||
func runGitIn(ctx context.Context, dir string, timeout time.Duration, args ...string) string {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return "[erreur] git n'est pas installé sur cette machine"
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "git", args...)
|
||||
cmd.Dir = agentCwd()
|
||||
cmd.Dir = dir
|
||||
// Jamais d'invite interactive (mot de passe, hôte inconnu) : un process
|
||||
// serveur n'a personne pour y répondre, il faut échouer vite et le dire.
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes")
|
||||
@@ -95,24 +139,32 @@ func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func toolGitStatus(ctx context.Context) string {
|
||||
return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch")
|
||||
func toolGitStatus(ctx context.Context, args map[string]any) string {
|
||||
dir, err := gitRepoDir(str(args["dir"]))
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return runGitIn(ctx, dir, 20*time.Second, "status", "--porcelain=v1", "--branch")
|
||||
}
|
||||
|
||||
func toolGitDiff(ctx context.Context, args map[string]any) string {
|
||||
dir, err := gitRepoDir(str(args["dir"]))
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
// Un seul appel qui montre TOUT ce qui a changé : index + arbre de travail.
|
||||
// HEAD peut ne pas exister (dépôt tout neuf) : on retombe sur le diff simple.
|
||||
gitArgs := []string{"diff", "HEAD"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
gitArgs = append(gitArgs, "--", file)
|
||||
}
|
||||
out := runGit(ctx, 30*time.Second, gitArgs...)
|
||||
out := runGitIn(ctx, dir, 30*time.Second, gitArgs...)
|
||||
if strings.HasPrefix(out, "[erreur]") && strings.Contains(out, "HEAD") {
|
||||
fallback := []string{"diff"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
fallback = append(fallback, "--", file)
|
||||
}
|
||||
out = runGit(ctx, 30*time.Second, fallback...)
|
||||
out = runGitIn(ctx, dir, 30*time.Second, fallback...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package loki
|
||||
|
||||
// code_instructions.go — consignes du dépôt (AGENTS.md, CLAUDE.md) en mode Code.
|
||||
// Repris de context/instructions.ts d'OpenFox.
|
||||
//
|
||||
// Un dépôt qui documente ses commandes de build et de test, ses conventions,
|
||||
// ses pièges, le fait dans ces fichiers. Sans eux, le modèle les redécouvrait
|
||||
// à coups de read et de bash — quand il les redécouvrait. Le contenu part avec
|
||||
// le contexte du projet : déplacé dans le premier message utilisateur par
|
||||
// normalizeSystemMessages, il ne touche pas au préfixe système (cache).
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const codeInstructionsPrefix = "Repository instructions"
|
||||
|
||||
// codeInstructionsMax : au-delà, on tronque (≈ 1k tokens) — ces fichiers
|
||||
// peuvent être longs, et le contexte est compté.
|
||||
const codeInstructionsMax = 4000
|
||||
|
||||
// codeInstructionFiles : par ordre de préférence ; le premier trouvé gagne.
|
||||
var codeInstructionFiles = []string{"AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"}
|
||||
|
||||
// codeInstructionsMessage renvoie les consignes du dépôt de la discussion
|
||||
// (racine, ou dépôt cloné détecté par gitRepoDir), ok=false s'il n'y en a pas.
|
||||
func codeInstructionsMessage(caps Caps) (Message, bool) {
|
||||
if !caps.Code {
|
||||
return Message{}, false
|
||||
}
|
||||
dirs := []string{agentCwd()}
|
||||
if d, err := gitRepoDir(""); err == nil && d != dirs[0] {
|
||||
dirs = append([]string{d}, dirs...)
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
for _, name := range codeInstructionFiles {
|
||||
b, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil || len(strings.TrimSpace(string(b))) == 0 {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(string(b))
|
||||
if r := []rune(s); len(r) > codeInstructionsMax {
|
||||
s = string(r[:codeInstructionsMax]) + "\n…[tronqué — lis le fichier pour la suite]"
|
||||
}
|
||||
rel := name
|
||||
if r, err := filepath.Rel(agentCwd(), filepath.Join(dir, name)); err == nil {
|
||||
rel = filepath.ToSlash(r)
|
||||
}
|
||||
return Message{Role: "system", Content: codeInstructionsPrefix + " (" + rel + ") — follow them:\n\n" + s}, true
|
||||
}
|
||||
}
|
||||
return Message{}, false
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// AGENTS.md du dépôt cloné part avec le contexte en mode Code, déplacé dans le
|
||||
// premier message utilisateur (préfixe système intact).
|
||||
func TestConsignesDuDepot(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
if _, ok := codeInstructionsMessage(Caps{Code: true}); ok {
|
||||
t.Fatal("consignes trouvées dans un dossier vide")
|
||||
}
|
||||
repo := filepath.Join(agentCwd(), "projet")
|
||||
_ = os.MkdirAll(filepath.Join(repo, ".git"), 0o755)
|
||||
if err := os.WriteFile(filepath.Join(repo, "AGENTS.md"), []byte("Build : make test\n"+strings.Repeat("x", 5000)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := codeInstructionsMessage(Caps{}); ok {
|
||||
t.Fatal("consignes injectées hors mode Code")
|
||||
}
|
||||
m, ok := codeInstructionsMessage(Caps{Code: true})
|
||||
s := msgText(m)
|
||||
if !ok || !strings.Contains(s, "make test") || !strings.Contains(s, "projet/AGENTS.md") || !strings.Contains(s, "tronqué") {
|
||||
t.Fatalf("message = %.200q", s)
|
||||
}
|
||||
out := normalizeSystemMessages([]Message{{Role: "system", Content: "base"}, m, {Role: "user", Content: "fais-le"}})
|
||||
if msgText(out[0]) != "base" || !strings.Contains(msgText(out[1]), "make test") {
|
||||
t.Fatalf("consignes restées dans le système : %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Le schéma de write annonce le chemin AVANT le contenu.
|
||||
func TestSchemaWriteCheminDAbord(t *testing.T) {
|
||||
b, _ := json.Marshal(writeTool().Function.Parameters)
|
||||
s := string(b)
|
||||
if i, j := strings.Index(s, `"file"`), strings.Index(s, `"content"`); i < 0 || j < 0 || i > j {
|
||||
t.Fatalf("ordre des propriétés : %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// write sur un fichier existant jamais lu : refusé dès que le chemin est
|
||||
// complet, sans attendre le contenu ; le fichier reste intact.
|
||||
func TestPreVolCoupeLEcritureRefusee(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
cible := filepath.Join(agentCwd(), "main.go")
|
||||
if err := os.WriteFile(cible, []byte("package main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int32
|
||||
var second []Message
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
chunk := func(args string) {
|
||||
esc := strings.ReplaceAll(args, `"`, `\"`)
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"w1","type":"function","function":{"name":"write","arguments":"` + esc + `"}}]}}]}` + "\n\n"))
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
chunk(`{"file":"main.go",`)
|
||||
chunk(`"content":"package main // début`)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
chunk(` FIN-JAMAIS-LUE"}`)
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
second = body.Messages
|
||||
_, _ = w.Write([]byte(sseChunk("je lis d'abord")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var results []string
|
||||
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "réécris main.go"}}, 0.7, Caps{Agent: true, Code: true}, func(ev StreamEvent) bool {
|
||||
if ev.ToolUsed != nil && ev.ToolUsed.Done {
|
||||
results = append(results, ev.ToolUsed.Result)
|
||||
}
|
||||
if ev.ToolUsed != nil && strings.Contains(ev.ToolUsed.Body, "FIN-JAMAIS-LUE") {
|
||||
t.Error("le contenu a continué d'arriver après le refus")
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || !strings.Contains(results[0], "[refusé]") || !strings.Contains(results[0], "interrompue") {
|
||||
t.Fatalf("résultats = %q", results)
|
||||
}
|
||||
if b, _ := os.ReadFile(cible); string(b) != "package main\n" {
|
||||
t.Fatalf("fichier modifié : %q", b)
|
||||
}
|
||||
if len(second) < 2 || second[len(second)-1].Role != "tool" || !strings.Contains(msgText(second[len(second)-1]), "[refusé]") {
|
||||
t.Fatalf("le refus n'a pas été rendu au modèle : %+v", second)
|
||||
}
|
||||
}
|
||||
+118
-12
@@ -14,7 +14,9 @@ package loki
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
|
||||
@@ -29,6 +31,10 @@ var defaultRetryPatterns = []string{
|
||||
`\bfunctions\.\w+\s*\(`,
|
||||
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
|
||||
`\[TOOL_REQUEST\]`,
|
||||
// Format XML de Qwen3-Coder, quand le gabarit du moteur ne l'a pas converti
|
||||
// en tool_calls (OpenFox 2.0.15x) : <function=bash><parameter=command>…
|
||||
`<function=\w+>`,
|
||||
`<parameter=\w+>`,
|
||||
}
|
||||
|
||||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
||||
@@ -37,22 +43,27 @@ var defaultRetryPatterns = []string{
|
||||
var (
|
||||
retryOnce sync.Once
|
||||
retryRes []*regexp.Regexp
|
||||
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
|
||||
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
|
||||
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
|
||||
retryCustom bool
|
||||
)
|
||||
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
retryOnce.Do(func() { retryRes = compileRetryPatterns() })
|
||||
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
|
||||
return retryRes
|
||||
}
|
||||
|
||||
func compileRetryPatterns() []*regexp.Regexp {
|
||||
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
|
||||
pats := defaultRetryPatterns
|
||||
custom := false
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
|
||||
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
|
||||
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
|
||||
if raw := getStr(bkState, "retry_patterns"); raw != "" {
|
||||
var custom []string
|
||||
if json.Unmarshal([]byte(raw), &custom) == nil && len(custom) > 0 {
|
||||
pats = custom
|
||||
var over []string
|
||||
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
|
||||
pats, custom = over, true
|
||||
}
|
||||
}
|
||||
var out []*regexp.Regexp
|
||||
@@ -61,24 +72,119 @@ func compileRetryPatterns() []*regexp.Regexp {
|
||||
out = append(out, re)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, custom
|
||||
}
|
||||
|
||||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
||||
// écrit en toutes lettres au lieu d'être émis par le protocole.
|
||||
func textualToolCall(content string) bool {
|
||||
return textualToolCallSnippet(content) != ""
|
||||
}
|
||||
|
||||
// retryScanMargin / retryScanMaxBack : marge relue avant la fin du balayage
|
||||
// précédent, et recul au-delà duquel on retombe sur le balayage complet.
|
||||
const (
|
||||
retryScanMargin = 512
|
||||
retryScanMaxBack = 16 << 10
|
||||
)
|
||||
|
||||
// textualToolCallFrom : textualToolCall pendant le flux, quand content[:scanned]
|
||||
// a DÉJÀ été balayé sans rien trouver. Relire toute la réponse à chaque morceau
|
||||
// coûtait jusqu'à 360 µs par jeton sur 80 Ko — du CPU volé au décodage quand
|
||||
// des experts tournent sur le processeur. Une correspondance neuve finit
|
||||
// forcément après scanned : on ne relit que la fin, à partir d'un vrai début de
|
||||
// ligne (sinon `^` verrait un début de ligne au milieu d'une phrase et
|
||||
// relancerait un tour parfaitement bon). Motifs de la surcharge : balayage
|
||||
// complet, rien ne borne leur portée.
|
||||
func textualToolCallFrom(content string, scanned int) bool {
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
for _, re := range retryPatterns() {
|
||||
if re.MatchString(content) {
|
||||
res := retryPatterns()
|
||||
start := 0
|
||||
if !retryCustom {
|
||||
start = retryScanStart(content, scanned)
|
||||
}
|
||||
w := content[start:]
|
||||
for _, re := range res {
|
||||
if re.MatchString(w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// retryCorrective : le message réinjecté pour relancer le tour.
|
||||
const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " +
|
||||
"Tool calls must go through the tool-call protocol, never in the answer text. " +
|
||||
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
||||
// retryScanStart : début de la fenêtre à relire. On part de scanned − marge,
|
||||
// puis on recule tant qu'on est dans une suite que les motifs embarqués peuvent
|
||||
// traverser sur toute sa longueur (\w+, \s*, `{"name":`, multioctet) : un
|
||||
// `<function=` suivi de 600 lettres, ou un `{"name":` noyé dans les blancs,
|
||||
// reste donc entier dans la fenêtre. Enfin, début de la ligne. Recul trop long
|
||||
// ou ligne sans fin : 0, le balayage complet d'avant.
|
||||
func retryScanStart(content string, scanned int) int {
|
||||
if scanned > len(content) {
|
||||
scanned = len(content)
|
||||
}
|
||||
p := scanned - retryScanMargin
|
||||
if p <= 0 {
|
||||
return 0
|
||||
}
|
||||
floor := scanned - retryScanMaxBack
|
||||
for p > 0 && retryRunByte(content[p-1]) {
|
||||
p--
|
||||
if p < floor {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
i := strings.LastIndexByte(content[:p], '\n')
|
||||
if i < 0 || i+1 < floor {
|
||||
return 0
|
||||
}
|
||||
return i + 1
|
||||
}
|
||||
|
||||
func retryRunByte(c byte) bool {
|
||||
switch {
|
||||
case c >= 0x80, c == '_', c >= '0' && c <= '9', c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
|
||||
return true
|
||||
}
|
||||
switch c {
|
||||
case ' ', '\t', '\n', '\r', '\f', '"', ':', '{':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// textualToolCallSnippet renvoie l'extrait fautif (le motif reconnu et un peu
|
||||
// de ce qui le suit), "" si aucun. Cité dans la consigne corrective : le
|
||||
// modèle voit CE qu'il a mal écrit au lieu d'une remontrance abstraite.
|
||||
func textualToolCallSnippet(content string) string {
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
for _, re := range retryPatterns() {
|
||||
if loc := re.FindStringIndex(content); loc != nil {
|
||||
end := loc[1] + 80
|
||||
if end > len(content) {
|
||||
end = len(content)
|
||||
}
|
||||
for end < len(content) && !utf8.RuneStart(content[end]) {
|
||||
end++
|
||||
}
|
||||
return strings.TrimSpace(content[loc[0]:end])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// maxPatternRetries : relances « appel écrit en texte » consécutives. Le
|
||||
// compteur repart à zéro après chaque appel d'outil réussi (OpenFox) : un long
|
||||
// tour d'agent peut rater la syntaxe plusieurs fois, mais pas en boucle.
|
||||
const maxPatternRetries = 3
|
||||
|
||||
// retryCorrective : le message réinjecté pour relancer le tour, avec l'extrait
|
||||
// fautif.
|
||||
func retryCorrective(snippet string) string {
|
||||
return retryCorrectiveLead + snippet +
|
||||
"\nTool calls must go through the tool-call protocol, never in the answer text. " +
|
||||
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Les appels d'outils écrits en texte doivent être détectés…
|
||||
func TestAppelOutilTextuelDetecte(t *testing.T) {
|
||||
@@ -32,3 +42,60 @@ func TestReponseNormaleNonRelancee(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format XML de Qwen3-Coder resté en texte : détecté, et l'extrait est cité.
|
||||
func TestAppelOutilQwenXMLDetecte(t *testing.T) {
|
||||
s := "Je regarde.\n<function=bash>\n<parameter=command>ls -la</parameter>\n</function>"
|
||||
snip := textualToolCallSnippet(s)
|
||||
if snip == "" || !strings.HasPrefix(snip, "<function=bash>") {
|
||||
t.Fatalf("extrait = %q", snip)
|
||||
}
|
||||
if textualToolCall("Un <div class=\"x\"> et un <param name=\"a\"> en HTML.") {
|
||||
t.Fatal("HTML ordinaire pris pour un appel d'outil")
|
||||
}
|
||||
}
|
||||
|
||||
// Le faux appel est repéré PENDANT le flux : la génération est coupée (la suite
|
||||
// n'arrive jamais à l'écran) et la relance cite l'extrait fautif.
|
||||
func TestAppelTextuelCoupeLeFlux(t *testing.T) {
|
||||
testHome(t)
|
||||
var n int32
|
||||
var second string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
_, _ = w.Write([]byte(sseChunk("Je lance <tool_call>")))
|
||||
w.(http.Flusher).Flush()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(sseChunk("FIN-INUTILE")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
second = msgText(body.Messages[len(body.Messages)-1])
|
||||
_, _ = w.Write([]byte(sseChunk("ok")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var content strings.Builder
|
||||
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "liste"}}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
|
||||
content.WriteString(ev.Content)
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(content.String(), "FIN-INUTILE") {
|
||||
t.Fatal("le flux n'a pas été coupé sur l'appel écrit en texte")
|
||||
}
|
||||
if !strings.Contains(second, "<tool_call>") || !strings.Contains(second, "WRITTEN AS TEXT") {
|
||||
t.Fatalf("relance sans l'extrait fautif : %q", second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package loki
|
||||
|
||||
// code_subagent.go — l'outil `subagent` du mode code : déléguer une question
|
||||
// bornée à un RÔLE qui travaille dans SON PROPRE contexte, et n'en ramener que
|
||||
// la réponse.
|
||||
//
|
||||
// Repris de l'idée des sous-agents d'OpenFox (voir NOTICE.md), réécrit pour le
|
||||
// fil unique de Loki, sur la mécanique déjà en place pour la passe de
|
||||
// vérification (code_verify.go : un runChat isolé, non persisté).
|
||||
//
|
||||
// Pourquoi ça compte sur un modèle LOCAL : la fenêtre de contexte est petite.
|
||||
// « Trouve où est géré le cache » coûte dix lectures de fichiers qui restent
|
||||
// ensuite dans l'historique jusqu'à la compaction, alors que seule la RÉPONSE
|
||||
// comptait. Le sous-agent paie ces lectures dans un contexte jetable et ne rend
|
||||
// que le résultat : le fil du builder ne grossit que d'un résultat d'outil.
|
||||
//
|
||||
// Trois rôles, tous en LECTURE SEULE (voir EnabledTools) : explorer (cartographie
|
||||
// le code), code-reviewer (relit un changement), planner (découpe un travail).
|
||||
// Aucun n'écrit : ce qui modifie le dépôt reste dans le fil principal, sous les
|
||||
// yeux de l'utilisateur.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// subagentRoles : les rôles délégables. `verifier` n'en fait PAS partie — cette
|
||||
// passe est pilotée par la boucle du contrat (code_verify.go), c'est la seule
|
||||
// habilitée à marquer un critère passé, et la laisser s'appeler à la demande
|
||||
// permettrait au builder de se décerner son propre satisfecit.
|
||||
var subagentRoles = []string{"explorer", "code-reviewer", "planner"}
|
||||
|
||||
// subagentTemperature : voir toolSubagent.
|
||||
const subagentTemperature = 0.6
|
||||
|
||||
func isSubagentRole(r string) bool {
|
||||
for _, x := range subagentRoles {
|
||||
if x == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// subagentMaxOutput borne ce que le sous-agent ramène dans le contexte de
|
||||
// l'appelant. Au-delà, l'économie de contexte serait perdue — c'est un rapport,
|
||||
// pas un vidage de fichiers.
|
||||
const subagentMaxOutput = 6000
|
||||
|
||||
func subagentTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "subagent",
|
||||
// Description au plus court : elle part dans CHAQUE requête (budget du
|
||||
// préambule, cf. TestSystemPromptStaysLean).
|
||||
Description: "Délègue une recherche ou une relecture à un rôle qui travaille dans son propre contexte et ne rend que sa réponse. Lecture seule.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"role": map[string]any{
|
||||
"type": "string",
|
||||
"enum": subagentRoles,
|
||||
"description": "explorer (trouver/cartographier), code-reviewer (relire un changement), planner (découper)",
|
||||
},
|
||||
"task": map[string]any{"type": "string", "description": "La question, précise et autonome : le rôle ne voit pas la discussion."},
|
||||
},
|
||||
"required": []string{"role", "task"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// toolSubagent exécute la délégation. Le contexte du sous-agent est ISOLÉ : le
|
||||
// prompt de son rôle, le briefing machine, et la tâche — rien de l'historique
|
||||
// du fil, que précisément on ne veut pas payer deux fois. Sa trace n'est ni
|
||||
// persistée ni diffusée : seul son texte final revient.
|
||||
func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string {
|
||||
role, _ := args["role"].(string)
|
||||
task, _ := args["task"].(string)
|
||||
role = strings.TrimSpace(role)
|
||||
task = strings.TrimSpace(task)
|
||||
if !isSubagentRole(role) {
|
||||
return "[erreur] rôle inconnu : " + role + " (attendu : " + strings.Join(subagentRoles, ", ") + ")"
|
||||
}
|
||||
if task == "" {
|
||||
return "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire"
|
||||
}
|
||||
// Garde-fou anti-récursion : un sous-agent ne délègue pas à son tour. Sans
|
||||
// ça, un modèle qui boucle ouvre autant de contextes que de tours, et la
|
||||
// facture en jetons devient exponentielle pour une question unique.
|
||||
if parent.Role != "" && parent.Role != "builder" {
|
||||
return "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
|
||||
}
|
||||
|
||||
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
|
||||
// Cadrage : le rôle ne voit ni la discussion ni l'utilisateur. Son prompt de
|
||||
// rôle parle à un humain (« termine en demandant confirmation ») ; ici son
|
||||
// interlocuteur est le builder, et sa réponse est un résultat d'outil.
|
||||
framing := "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
if role == "planner" {
|
||||
framing = "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
}
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
|
||||
{Role: "user", Content: task + framing},
|
||||
}
|
||||
var out strings.Builder
|
||||
// Le sous-agent occupe le slot de la conversation du builder : une fois son
|
||||
// rapport rendu, on l'efface pour que le builder reparte du cache RAM
|
||||
// (llm_slots.go). Pas entre ses lectures : seulement à la fin.
|
||||
defer engineSideJob()()
|
||||
// Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit
|
||||
// la bulle de l'outil `subagent` et son rapport, pas les dix lectures.
|
||||
// Température 0.6 et non 0 : en glouton, Qwen avec réflexion tourne vite en
|
||||
// boucle (OpenFox l'a relevé). Le TEMP du preset, s'il est posé, l'emporte.
|
||||
// Seul le texte écrit APRÈS le dernier outil revient au builder : la
|
||||
// narration entre deux lectures n'est pas le rapport.
|
||||
if _, err := runChat(withPerfKind(ctx, perfSubagent), msgs, subagentTemperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.ToolUsed != nil {
|
||||
out.Reset()
|
||||
}
|
||||
if ev.Content != "" {
|
||||
out.WriteString(ev.Content)
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
return "[erreur] sous-agent " + role + " : " + err.Error()
|
||||
}
|
||||
res := strings.TrimSpace(out.String())
|
||||
if res == "" {
|
||||
return "[erreur] le sous-agent " + role + " n'a rien répondu"
|
||||
}
|
||||
if r := []rune(res); len(r) > subagentMaxOutput {
|
||||
res = string(r[:subagentMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return "[" + role + "]\n" + res
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les garde-fous de l'outil subagent doivent répondre SANS appeler le modèle :
|
||||
// un rôle inconnu, une tâche vide ou un appel depuis un sous-agent sont refusés
|
||||
// à l'entrée (sinon chaque faute de frappe coûte une génération complète).
|
||||
func TestSubagentRefusesBeforeAnyInference(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
parent Caps
|
||||
want string
|
||||
}{
|
||||
{"rôle inconnu", map[string]any{"role": "architecte", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"rôle verifier refusé", map[string]any{"role": "verifier", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"tâche vide", map[string]any{"role": "explorer", "task": " "}, Caps{Code: true}, "tâche vide"},
|
||||
{"pas de récursion", map[string]any{"role": "explorer", "task": "x"}, Caps{Code: true, Role: "explorer"}, "ne peut pas en appeler un autre"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := toolSubagent(context.Background(), tc.args, tc.parent)
|
||||
if !strings.HasPrefix(got, "[erreur]") || !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("attendu un refus contenant %q, obtenu %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Un rôle délégué travaille en LECTURE SEULE : ni write/edit (les modifications
|
||||
// restent dans le fil principal), ni subagent (pas de récursion), et seul le
|
||||
// planner pose des critères.
|
||||
func TestSubagentRolesAreReadOnly(t *testing.T) {
|
||||
forbidden := map[string]bool{"write": true, "edit": true, "subagent": true, "mem_add": true, "mem_edit": true, "web_search": true}
|
||||
for _, role := range subagentRoles {
|
||||
names := map[string]bool{}
|
||||
for _, tool := range EnabledTools(Caps{Agent: true, Code: true, Role: role}) {
|
||||
names[tool.Function.Name] = true
|
||||
}
|
||||
for f := range forbidden {
|
||||
if names[f] {
|
||||
t.Fatalf("le rôle %s ne devrait pas avoir l'outil %s", role, f)
|
||||
}
|
||||
}
|
||||
if !names["read"] || !names["grep"] {
|
||||
t.Fatalf("le rôle %s devrait pouvoir lire le code", role)
|
||||
}
|
||||
if got := names["criteria"]; got != (role == "planner") {
|
||||
t.Fatalf("critères pour %s : %v (attendu %v)", role, got, role == "planner")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,3 +157,28 @@ func TestEditCRLFMultiligne(t *testing.T) {
|
||||
t.Fatalf("résultat inattendu : %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// git_status / git_diff trouvent le dépôt cloné dans un sous-dossier de la
|
||||
// discussion, et demandent de choisir quand il y en a plusieurs.
|
||||
func TestGitRepoDirDetecteLeClone(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
if d, err := gitRepoDir(""); err != nil || d != agentCwd() {
|
||||
t.Fatalf("sans dépôt : %q, %v", d, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(agentCwd(), "projet", ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d, err := gitRepoDir(""); err != nil || filepath.Base(d) != "projet" {
|
||||
t.Fatalf("un clone : %q, %v (ws %s)", d, err, ws)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(agentCwd(), "autre", ".git"), 0o755)
|
||||
if _, err := gitRepoDir(""); err == nil {
|
||||
t.Fatal("deux dépôts : il fallait demander dir")
|
||||
}
|
||||
if d, err := gitRepoDir("autre"); err != nil || filepath.Base(d) != "autre" {
|
||||
t.Fatalf("dir explicite : %q, %v", d, err)
|
||||
}
|
||||
if _, err := gitRepoDir("../dehors"); err == nil {
|
||||
t.Fatal("dir hors de la discussion accepté")
|
||||
}
|
||||
}
|
||||
+116
-25
@@ -25,10 +25,19 @@ import (
|
||||
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
|
||||
const codeMaxFixTurns = 2
|
||||
|
||||
// codeMaxContinue : relances du builder qui a rendu la main avec des critères
|
||||
// encore ouverts (« ensuite je vais… ») avant de vérifier quand même. Reprise
|
||||
// du buildAgentNudge d'OpenFox : une passe de vérification sur un travail
|
||||
// inachevé, c'est un prefill complet, le cache KV du fil évincé, et des
|
||||
// « failed » qui ne disent que « pas encore fait ».
|
||||
const codeMaxContinue = 2
|
||||
|
||||
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
|
||||
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
|
||||
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
|
||||
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère), ni si le
|
||||
// tour s'est terminé sur une question à l'utilisateur (asked) : sa réponse
|
||||
// passe avant toute vérification.
|
||||
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int, asked bool) {
|
||||
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil || asked {
|
||||
return
|
||||
}
|
||||
convID := convEnsureActive()
|
||||
@@ -40,6 +49,24 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
|
||||
if caps.Role == "planner" {
|
||||
return
|
||||
}
|
||||
// Le builder vérifie lui-même puis marque « completed » ce qu'il estime
|
||||
// fait. Tant qu'il reste des critères ouverts, on le relance plutôt que de
|
||||
// vérifier un travail inachevé.
|
||||
for n := 0; n < codeMaxContinue; n++ {
|
||||
open := critOpen(list)
|
||||
if len(open) == 0 {
|
||||
break
|
||||
}
|
||||
msg := "Not done yet — these acceptance criteria are still open:\n" + critRender(open) +
|
||||
"\n\nContinue working on them. Once one is done and checked, mark it completed (criteria action=set, status=completed). If you need the user's decision, use ask."
|
||||
if c.runBuilderTurn(ctx, caps, temperature, epoch, msg) || ctx.Err() != nil {
|
||||
return // question posée à l'utilisateur, ou arrêt
|
||||
}
|
||||
list = critList(convID)
|
||||
if critAllPassed(list) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for fix := 0; ; fix++ {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
@@ -59,7 +86,9 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
|
||||
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
|
||||
return
|
||||
}
|
||||
c.runFixTurn(ctx, caps, temperature, epoch, list)
|
||||
if c.runFixTurn(ctx, caps, temperature, epoch, list) {
|
||||
return // le builder attend une réponse de l'utilisateur
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,47 +112,75 @@ func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature
|
||||
{Role: "user", Content: user.String()},
|
||||
}
|
||||
// Trace ISOLÉE : ni persistée dans c.Messages, ni compactée — elle vit et
|
||||
// meurt dans cette passe. Seuls les deltas d'affichage sortent.
|
||||
_, _ = runChat(ctx, msgs, temperature, vcaps, func(ev StreamEvent) bool {
|
||||
c.forwardStream(ev, epoch)
|
||||
// meurt dans cette passe. Seuls les deltas d'affichage sortent. Son état
|
||||
// dans le moteur aussi : effacé à la fin de la passe (llm_slots.go).
|
||||
defer engineSideJob()()
|
||||
_, _ = runChat(withPerfKind(ctx, perfVerify), msgs, temperature, vcaps, func(ev StreamEvent) bool {
|
||||
c.forwardStream(ev, epoch, true)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
|
||||
// critères en échec. Sa trace est persistée comme un tour ordinaire.
|
||||
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
|
||||
// critères en échec. Sa trace est persistée comme un tour ordinaire. Renvoie
|
||||
// true si le builder a posé une question à l'utilisateur (ask).
|
||||
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) bool {
|
||||
var failed []Criterion
|
||||
for _, cr := range list {
|
||||
if cr.Status != "passed" {
|
||||
failed = append(failed, cr)
|
||||
}
|
||||
}
|
||||
return c.runBuilderTurn(ctx, caps, temperature, epoch, "Verification failed on these criteria:\n"+critRender(failed)+
|
||||
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed. Mark each fixed criterion completed again.")
|
||||
}
|
||||
|
||||
// runBuilderTurn relance le BUILDER sur l'historique normal avec une consigne
|
||||
// (correction après vérification, ou relance sur des critères ouverts). Sa
|
||||
// trace est persistée comme un tour ordinaire. Renvoie true si le builder a
|
||||
// posé une question à l'utilisateur (ask) : la boucle doit alors s'arrêter.
|
||||
func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperature float64, epoch int, instruction string) (asked bool) {
|
||||
c.appendDelta(epoch, map[string]any{"role": "builder"})
|
||||
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
||||
|
||||
fixMsg := Message{Role: "user", Content: "Verification failed on these criteria:\n" + critRender(failed) +
|
||||
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed."}
|
||||
fixMsg := Message{Role: "user", Content: instruction}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.epoch != epoch {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
return false
|
||||
}
|
||||
c.Messages = append(c.Messages, fixMsg)
|
||||
msgs := append([]Message(nil), c.Messages...)
|
||||
c.mu.Unlock()
|
||||
|
||||
final := msgs
|
||||
// Même contexte que le tour de build : projet (description, index mémoire,
|
||||
// trackers) et consignes du dépôt. Sans eux, la reprise changeait le début
|
||||
// du premier message utilisateur — tout le cache de prompt à refaire.
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
var content strings.Builder
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
sawUsage := false
|
||||
sent, tools := prepareTurn(final, caps)
|
||||
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 {
|
||||
sawUsage = true
|
||||
}
|
||||
if ev.Content != "" {
|
||||
content.WriteString(ev.Content)
|
||||
}
|
||||
c.forwardStream(ev, epoch)
|
||||
if ev.ToolUsed != nil {
|
||||
content.Reset() // déjà dans le message tool_calls (voir generate)
|
||||
}
|
||||
if ev.Ask != nil {
|
||||
asked = true
|
||||
}
|
||||
c.forwardStream(ev, epoch, false)
|
||||
return true
|
||||
})
|
||||
c.mu.Lock()
|
||||
@@ -132,15 +189,28 @@ func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature fl
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
|
||||
}
|
||||
c.Messages = dropStrayNudges(c.Messages) // même règle que generate
|
||||
if sawUsage {
|
||||
c.ctxUsedLen = len(c.Messages) // même règle que generate
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
// En plein tour : l'étape suivante part vers le modèle juste après, le fsync
|
||||
// ne doit pas l'attendre (chat_persist.go).
|
||||
c.persistAsync()
|
||||
return asked
|
||||
}
|
||||
|
||||
// forwardStream relaie les événements d'un runChat secondaire (vérification,
|
||||
// correction) vers le journal d'affichage — même mapping que generate(), sans
|
||||
// la gestion de compaction (ces passes n'en déclenchent pas : contexte court).
|
||||
func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
|
||||
//
|
||||
// isolated : la passe tourne sur sa propre trace (vérificateur), PAS sur
|
||||
// l'historique de la discussion. Ses comptes ne disent rien du contexte de
|
||||
// celle-ci : ils écrasaient CtxUsed avec la petite taille du vérificateur, et
|
||||
// le contrôle de fin de tour — puis celui du tour suivant — décidaient sur ce
|
||||
// chiffre-là. La jauge de l'UI ne doit pas bouger non plus (ctx_isolated).
|
||||
func (c *Conversation) forwardStream(ev StreamEvent, epoch int, isolated bool) {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||
@@ -151,24 +221,44 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
|
||||
}
|
||||
if ev.ToolUsed.Body != "" {
|
||||
tu["body"] = ev.ToolUsed.Body
|
||||
tu["body_lines"] = ev.ToolUsed.BodyLines
|
||||
if ev.ToolUsed.BodyTail {
|
||||
tu["body_tail"] = true
|
||||
}
|
||||
}
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
tu["added"] = ev.ToolUsed.Added
|
||||
tu["removed"] = ev.ToolUsed.Removed
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
}
|
||||
if ev.ToolUsed.ResultID != "" {
|
||||
tu["result_id"] = ev.ToolUsed.ResultID
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.Ask != nil:
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
case ev.Stats != nil:
|
||||
if ev.Stats.PromptTokensTotal > 0 {
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if isolated {
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats, "ctx_isolated": true})
|
||||
break
|
||||
}
|
||||
local := !externalActive()
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
if ev.Stats.PromptTokensTotal > 0 {
|
||||
c.CtxUsed = ev.Stats.ctxAfter()
|
||||
}
|
||||
if local {
|
||||
c.genPeak = max(c.genPeak, ev.Stats.GenTokens)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||
@@ -184,7 +274,8 @@ func (c *Conversation) lastUserText() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for i := len(c.Messages) - 1; i >= 0; i-- {
|
||||
if c.Messages[i].Role != "user" {
|
||||
// Un rappel de loki (budget, relance) n'est pas la tâche.
|
||||
if c.Messages[i].Role != "user" || isLokiInjected(c.Messages[i]) {
|
||||
continue
|
||||
}
|
||||
if s, ok := c.Messages[i].Content.(string); ok {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// moteurScripte : faux llama-server qui répond selon le DERNIER message reçu.
|
||||
// Un message `tool` en queue → texte de clôture ; sinon la règle dont le motif
|
||||
// figure dans le dernier message utilisateur → appel d'outil criteria.
|
||||
func moteurScripte(t *testing.T, regles map[string]string) (port string, vus *[]string) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var log []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
last := body.Messages[len(body.Messages)-1]
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
args := ""
|
||||
if last.Role == "user" {
|
||||
txt := msgText(last)
|
||||
mu.Lock()
|
||||
log = append(log, txt)
|
||||
mu.Unlock()
|
||||
for motif, a := range regles {
|
||||
if strings.Contains(txt, motif) {
|
||||
args = a
|
||||
}
|
||||
}
|
||||
}
|
||||
if args != "" {
|
||||
esc := strings.ReplaceAll(args, `"`, `\"`)
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"criteria","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(sseChunk("ok")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
}
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
return u.Port(), &log
|
||||
}
|
||||
|
||||
// Le builder rend la main avec un critère encore ouvert : on le RELANCE (il le
|
||||
// marque completed) avant de vérifier, au lieu de vérifier un travail
|
||||
// inachevé (repris du buildAgentNudge d'OpenFox).
|
||||
func TestVerificationAttendQueLeBuilderAitFini(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||
port, vus := moteurScripte(t, map[string]string{
|
||||
"Not done yet": `{"action":"set","id":"1","status":"completed"}`,
|
||||
"Verify each non-pass": `{"action":"set","id":1,"status":"passed"}`,
|
||||
})
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
|
||||
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, false)
|
||||
|
||||
if len(*vus) < 2 || !strings.Contains((*vus)[0], "Not done yet") || !strings.Contains((*vus)[1], "Verify each non-pass") {
|
||||
t.Fatalf("ordre des passes = %q, attendu relance du builder PUIS vérification", *vus)
|
||||
}
|
||||
if l := critList(id); len(l) != 1 || l[0].Status != "passed" {
|
||||
t.Fatalf("critère final : %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
// Tour terminé sur une question à l'utilisateur : aucune passe ne part avant
|
||||
// sa réponse.
|
||||
func TestPasDeVerificationApresUneQuestion(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||
port, vus := moteurScripte(t, nil)
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
|
||||
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, true)
|
||||
if len(*vus) != 0 {
|
||||
t.Fatalf("%d requête(s) au moteur alors qu'une question attend l'utilisateur", len(*vus))
|
||||
}
|
||||
}
|
||||
|
||||
// Un id de critère écrit entre guillemets (« "2" », « "#2" ») vise bien #2.
|
||||
func TestCritereIdEnChaine(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"a", "b"}}, false)
|
||||
if out := toolCriteria(map[string]any{"action": "set", "id": "#2", "status": "completed"}, false); !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("id en chaîne refusé : %s", out)
|
||||
}
|
||||
if l := critList(id); l[1].Status != "completed" || l[0].Status != "pending" {
|
||||
t.Fatalf("mauvais critère modifié : %+v", l)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fauxMoteurOutil : un llama-server de comédie qui, à la PREMIÈRE requête,
|
||||
// répond par un appel d'outil (name/args), puis par un texte à la suivante.
|
||||
func fauxMoteurOutil(t *testing.T, name, args string) (port string, requêtes *int32) {
|
||||
t.Helper()
|
||||
var n int32
|
||||
esc := strings.ReplaceAll(args, `"`, `\"`)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"` + name + `","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(sseChunk("Verdict enregistré.")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
}
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
return u.Port(), &n
|
||||
}
|
||||
|
||||
// La passe de vérification (Role=verifier) doit pouvoir marquer un critère
|
||||
// passed via l'outil criteria — c'est tout son rôle. Vécu : « en mode code,
|
||||
// le système de vérification ne coche plus les critères ».
|
||||
func TestPasseDeVerificationCocheLesCriteres(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
|
||||
|
||||
port, n := fauxMoteurOutil(t, "criteria", `{"action":"set","id":1,"status":"passed"}`)
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caps := Caps{Agent: true, Code: true, Role: "verifier"}
|
||||
var tools []string
|
||||
_, err := runChat(context.Background(), []Message{
|
||||
{Role: "system", Content: rolePrompt("verifier")},
|
||||
{Role: "user", Content: "Verify."},
|
||||
}, 0.2, caps, func(ev StreamEvent) bool {
|
||||
if ev.ToolUsed != nil && ev.ToolUsed.Done {
|
||||
tools = append(tools, ev.ToolUsed.Name+" → "+ev.ToolUsed.Result)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *n < 2 {
|
||||
t.Fatalf("%d requête(s) au moteur : l'appel d'outil n'a pas été suivi d'un second tour", *n)
|
||||
}
|
||||
list := critList(id)
|
||||
if len(list) != 1 || list[0].Status != "passed" {
|
||||
t.Fatalf("critère non coché par le vérificateur : %+v — outils : %v", list, tools)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
package loki
|
||||
|
||||
// computer_cdp.go — pilote un Chromium/Chrome LOCAL via le protocole DevTools
|
||||
// (CDP), en Go pur (websocket coder/websocket déjà présent), sans dépendance
|
||||
// lourde type chromedp/playwright. C'est le moteur « web » du computer use :
|
||||
// ouvrir une URL, lire l'arbre d'accessibilité (éléments interactifs numérotés,
|
||||
// façon set-of-marks), cliquer/taper/défiler, et — à la demande — capturer un
|
||||
// screenshot pour la vision.
|
||||
//
|
||||
// Choix d'archi : le conteneur Loki embarque déjà un Chromium (celui de
|
||||
// Playwright, cf. Dockerfile) ; on le pilote en CDP. Une seule session navigateur
|
||||
// est maintenue (lazy) et réutilisée entre les appels d'outils, comme le cache
|
||||
// web de chat_internet.go.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
var (
|
||||
cdpMu sync.Mutex
|
||||
cdpCur *cdpSession
|
||||
)
|
||||
|
||||
// Taille FIXE du viewport piloté (et donc du screenshot). Communiquée au modèle
|
||||
// pour que ses coordonnées browser_click_xy lues sur l'image tombent juste.
|
||||
const (
|
||||
cuViewW = 1280
|
||||
cuViewH = 800
|
||||
)
|
||||
|
||||
// cdpSession détient le process Chrome, la connexion websocket CDP vers la cible
|
||||
// « page », et le multiplexage requête/réponse (un lecteur en tâche de fond
|
||||
// distribue les réponses aux appels en attente par id).
|
||||
type cdpSession struct {
|
||||
cmd *exec.Cmd
|
||||
userDir string
|
||||
ws *websocket.Conn
|
||||
wctx context.Context
|
||||
wcancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[int]chan cdpMessage
|
||||
|
||||
netMu sync.Mutex
|
||||
inflight int // requêtes réseau en cours (pour l'attente d'inactivité)
|
||||
lastNet time.Time // dernier événement réseau
|
||||
|
||||
lastSnap string // dernier snapshot renvoyé, pour dé-dupliquer les états identiques
|
||||
}
|
||||
|
||||
type cdpMessage struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *cdpError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type cdpError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ─── découverte du binaire navigateur ────────────────────────────────────────
|
||||
|
||||
// chromePath localise un binaire Chromium/Chrome/Edge exploitable. LOKI_CHROME
|
||||
// force un chemin ; sinon on cherche D'ABORD le Chromium de Playwright (celui
|
||||
// qu'embarque l'image Docker, sous PLAYWRIGHT_BROWSERS_PATH), puis les
|
||||
// emplacements standards par OS, puis le PATH.
|
||||
//
|
||||
// L'ordre compte : dans le conteneur, AUCUN chromium n'est installé par le
|
||||
// gestionnaire de paquets — le seul navigateur présent est celui que
|
||||
// `playwright install chromium` a déposé dans /opt/pw-browsers. Sans cette
|
||||
// recherche, le pilotage de navigateur serait déclaré indisponible dans l'image
|
||||
// même qui embarque un navigateur (voir Dockerfile, ARG PLAYWRIGHT).
|
||||
func chromePath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("LOKI_CHROME")); p != "" {
|
||||
return p
|
||||
}
|
||||
if p := playwrightChromium(); p != "" {
|
||||
return p
|
||||
}
|
||||
var cands []string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)"), os.Getenv("LocalAppData")} {
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
cands = append(cands,
|
||||
filepath.Join(base, `Google\Chrome\Application\chrome.exe`),
|
||||
filepath.Join(base, `Microsoft\Edge\Application\msedge.exe`),
|
||||
filepath.Join(base, `Chromium\Application\chrome.exe`),
|
||||
)
|
||||
}
|
||||
case "darwin":
|
||||
cands = []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
}
|
||||
default:
|
||||
for _, n := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range cands {
|
||||
if c != "" {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"chrome", "google-chrome", "chromium"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// playwrightChromium renvoie le binaire Chromium déposé par Playwright, ou ""
|
||||
// s'il n'y en a pas. Playwright range ses navigateurs sous
|
||||
// PLAYWRIGHT_BROWSERS_PATH (l'image Docker fixe /opt/pw-browsers) ou, à défaut,
|
||||
// dans le cache utilisateur, en un dossier chromium-<build> par version.
|
||||
//
|
||||
// On prend la version la PLUS RÉCENTE (les builds sont numérotés, tri croissant
|
||||
// sur le nom) : une image reconstruite laisse parfois l'ancien dossier derrière
|
||||
// elle, et lancer un vieux Chromium sur le CDP d'aujourd'hui, c'est chercher
|
||||
// des pannes sans raison. `chrome` d'abord, `headless_shell` en repli : ce
|
||||
// dernier ne sait pas ouvrir de fenêtre (LOKI_CU_HEADFUL serait sans effet)
|
||||
// mais pilote très bien une page en headless.
|
||||
func playwrightChromium() string {
|
||||
var roots []string
|
||||
if p := strings.TrimSpace(os.Getenv("PLAYWRIGHT_BROWSERS_PATH")); p != "" && p != "0" {
|
||||
roots = append(roots, p)
|
||||
}
|
||||
roots = append(roots, "/opt/pw-browsers")
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
roots = append(roots, filepath.Join(home, "AppData", "Local", "ms-playwright"))
|
||||
case "darwin":
|
||||
roots = append(roots, filepath.Join(home, "Library", "Caches", "ms-playwright"))
|
||||
default:
|
||||
roots = append(roots, filepath.Join(home, ".cache", "ms-playwright"))
|
||||
}
|
||||
}
|
||||
// Chemin du binaire À L'INTÉRIEUR d'un dossier chromium-<build>, par OS.
|
||||
var rels [][]string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
rels = [][]string{{"chrome-win", "chrome.exe"}, {"chrome-win", "headless_shell.exe"}}
|
||||
case "darwin":
|
||||
rels = [][]string{{"chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"}, {"chrome-mac", "headless_shell"}}
|
||||
default:
|
||||
rels = [][]string{{"chrome-linux", "chrome"}, {"chrome-linux", "headless_shell"}}
|
||||
}
|
||||
for _, root := range roots {
|
||||
dirs, err := filepath.Glob(filepath.Join(root, "chromium*-*"))
|
||||
if err != nil || len(dirs) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
for _, rel := range rels {
|
||||
p := filepath.Join(append([]string{dirs[i]}, rel...)...)
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── lancement + connexion ───────────────────────────────────────────────────
|
||||
|
||||
func cdpLaunch() (*cdpSession, error) {
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
return nil, fmt.Errorf("navigateur Chromium/Chrome/Edge introuvable — installe-le ou définis LOKI_CHROME=<chemin du binaire>")
|
||||
}
|
||||
// Balaye les profils temporaires abandonnés (Chrome tué sans close() lors d'un
|
||||
// redémarrage du service) : sinon /tmp se remplit de dossiers loki-cu-* au fil
|
||||
// des restarts.
|
||||
sweepStaleCUDirs()
|
||||
userDir, err := os.MkdirTemp("", "loki-cu-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Headless par défaut (marche sur le serveur sans écran) ; LOKI_CU_HEADFUL=1
|
||||
// ouvre une vraie fenêtre visible sur une machine de bureau.
|
||||
args := []string{
|
||||
"--remote-debugging-port=0",
|
||||
"--user-data-dir=" + userDir,
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-gpu", "--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-features=Translate,BackForwardCache",
|
||||
// Indispensables sur un serveur headless sous un compte non-root : sans
|
||||
// --no-sandbox Chrome plante au lancement (namespaces refusés), et /dev/shm
|
||||
// y est souvent minuscule → --disable-dev-shm-usage évite les crash onglet.
|
||||
"--no-sandbox", "--disable-dev-shm-usage",
|
||||
"--window-size=1280,900",
|
||||
"about:blank",
|
||||
}
|
||||
if os.Getenv("LOKI_CU_HEADFUL") == "" {
|
||||
args = append([]string{"--headless=new"}, args...)
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
// Chrome écrit le port de debug réel dans DevToolsActivePort (ligne 1).
|
||||
port := ""
|
||||
portFile := filepath.Join(userDir, "DevToolsActivePort")
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(portFile); err == nil {
|
||||
if line := strings.SplitN(strings.TrimSpace(string(b)), "\n", 2)[0]; line != "" {
|
||||
port = line
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if port == "" {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, fmt.Errorf("Chrome n'a pas exposé son port de debug (DevToolsActivePort)")
|
||||
}
|
||||
wsURL, err := cdpPageWS(port)
|
||||
if err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
c.SetReadLimit(-1) // les screenshots dépassent la limite par défaut (32 Kio)
|
||||
s := &cdpSession{cmd: cmd, userDir: userDir, ws: c, wctx: ctx, wcancel: cancel, pending: map[int]chan cdpMessage{}}
|
||||
go s.readLoop()
|
||||
_, _ = s.call("Page.enable", nil)
|
||||
_, _ = s.call("Runtime.enable", nil)
|
||||
_, _ = s.call("DOM.enable", nil)
|
||||
_, _ = s.call("Network.enable", nil) // pour l'attente d'inactivité réseau (settle)
|
||||
// Viewport DÉTERMINISTE : sans ça, headless donne une taille bizarre (mesuré :
|
||||
// 1264x749) que le modèle interprétait mal pour browser_click_xy (il visait trop
|
||||
// bas). On force exactement cuViewW x cuViewH, DPR 1 → le screenshot fait cette
|
||||
// taille, et les coordonnées lues dessus = coordonnées de clic, 1:1.
|
||||
_, _ = s.call("Emulation.setDeviceMetricsOverride", map[string]any{
|
||||
"width": cuViewW, "height": cuViewH, "deviceScaleFactor": 1, "mobile": false,
|
||||
})
|
||||
// Garder toute navigation dans le MÊME onglet : le pilote n'est attaché qu'à une
|
||||
// cible « page ». Un lien target=_blank ou un window.open ouvrait un onglet que
|
||||
// le pilote ne voyait pas → l'IA croyait que « rien ne se passe » et bouclait
|
||||
// (vécu sur le test IONOS « Devenez client »). Ce script, réévalué au début de
|
||||
// CHAQUE document, neutralise les nouveaux onglets.
|
||||
_, _ = s.call("Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": cdpSameTabJS})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpSameTabJS force les navigations « nouvel onglet » à rester dans l'onglet
|
||||
// courant (window.open redirigé, target=_blank retiré au clic).
|
||||
const cdpSameTabJS = `(function(){try{
|
||||
window.open=function(u){if(u)location.href=u;return window;};
|
||||
document.addEventListener('click',function(e){
|
||||
var a=e.target&&e.target.closest&&e.target.closest('a[target]');
|
||||
if(a)a.removeAttribute('target');
|
||||
},true);
|
||||
}catch(e){}})();`
|
||||
|
||||
// cdpPageWS interroge l'API HTTP de debug pour trouver l'URL websocket d'une
|
||||
// cible de type « page ».
|
||||
func cdpPageWS(port string) (string, error) {
|
||||
cl := &http.Client{Timeout: 5 * time.Second}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := cl.Get("http://127.0.0.1:" + port + "/json")
|
||||
if err == nil {
|
||||
var targets []struct {
|
||||
Type string `json:"type"`
|
||||
WS string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&targets)
|
||||
resp.Body.Close()
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && t.WS != "" {
|
||||
return t.WS, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
return "", fmt.Errorf("aucune cible 'page' exposée par Chrome")
|
||||
}
|
||||
|
||||
func (s *cdpSession) readLoop() {
|
||||
for {
|
||||
_, data, err := s.ws.Read(s.wctx)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
for _, ch := range s.pending {
|
||||
close(ch)
|
||||
}
|
||||
s.pending = map[int]chan cdpMessage{}
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
var m cdpMessage
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
continue
|
||||
}
|
||||
if m.ID != 0 {
|
||||
s.mu.Lock()
|
||||
ch := s.pending[m.ID]
|
||||
delete(s.pending, m.ID)
|
||||
s.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Événements réseau : on suit le nombre de requêtes en vol pour que settle()
|
||||
// puisse attendre l'inactivité réseau (fin du chargement AJAX).
|
||||
switch m.Method {
|
||||
case "Network.requestWillBeSent":
|
||||
s.netMu.Lock()
|
||||
s.inflight++
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
case "Network.loadingFinished", "Network.loadingFailed":
|
||||
s.netMu.Lock()
|
||||
if s.inflight > 0 {
|
||||
s.inflight--
|
||||
}
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call envoie une commande CDP et attend sa réponse (borné à 30 s).
|
||||
func (s *cdpSession) call(method string, params map[string]any) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
ch := make(chan cdpMessage, 1)
|
||||
s.pending[id] = ch
|
||||
s.mu.Unlock()
|
||||
|
||||
payload := map[string]any{"id": id, "method": method}
|
||||
if params != nil {
|
||||
payload["params"] = params
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
wctx, cancel := context.WithTimeout(s.wctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if err := s.ws.Write(wctx, websocket.MessageText, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case m, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connexion CDP fermée")
|
||||
}
|
||||
if m.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", method, m.Error.Message)
|
||||
}
|
||||
return m.Result, nil
|
||||
case <-wctx.Done():
|
||||
return nil, fmt.Errorf("%s: délai dépassé", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.ws != nil {
|
||||
_ = s.ws.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
if s.wcancel != nil {
|
||||
s.wcancel()
|
||||
}
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
if s.userDir != "" {
|
||||
os.RemoveAll(s.userDir)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpGet renvoie la session courante, en (re)lançant Chrome au besoin. Un ping
|
||||
// léger valide qu'une session existante répond encore.
|
||||
func cdpGet() (*cdpSession, error) {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
if _, err := cdpCur.call("Runtime.evaluate", map[string]any{"expression": "1", "returnByValue": true}); err == nil {
|
||||
return cdpCur, nil
|
||||
}
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
s, err := cdpLaunch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdpCur = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpShutdown ferme la session navigateur si elle existe (appelé quand on coupe
|
||||
// le computer use, pour ne pas laisser un Chrome headless traîner).
|
||||
// sweepStaleCUDirs supprime les profils Chrome temporaires (loki-cu-*) de plus
|
||||
// d'une heure laissés dans le dossier temp — best-effort, silencieux.
|
||||
func sweepStaleCUDirs() {
|
||||
entries, err := os.ReadDir(os.TempDir())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.HasPrefix(e.Name(), "loki-cu-") {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
os.RemoveAll(filepath.Join(os.TempDir(), e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cdpShutdown() {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── évaluation JS ───────────────────────────────────────────────────────────
|
||||
|
||||
// cdpEval exécute une expression JS dans la page et renvoie sa valeur (JSON brut).
|
||||
func (s *cdpSession) eval(expr string) (json.RawMessage, error) {
|
||||
res, err := s.call("Runtime.evaluate", map[string]any{
|
||||
"expression": expr,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Result struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
} `json:"result"`
|
||||
ExceptionDetails *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"exceptionDetails"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &r)
|
||||
if r.ExceptionDetails != nil {
|
||||
return nil, fmt.Errorf("JS: %s", r.ExceptionDetails.Text)
|
||||
}
|
||||
return r.Result.Value, nil
|
||||
}
|
||||
|
||||
// ─── snapshot d'accessibilité (set-of-marks) ─────────────────────────────────
|
||||
|
||||
// cdpHelpersJS : fonctions partagées par le snapshot et browser_find, préfixées au
|
||||
// script évalué. Points clés :
|
||||
// - ajActionable ne garde que les éléments RÉELLEMENT cliquables/saisissables :
|
||||
// on écarte les conteneurs (list, listitem, banner, navigation, menu, region,
|
||||
// div/span muets…) qui polluaient la liste et embrouillaient le modèle.
|
||||
// - ajName enrichit un champ de formulaire avec son <label> (« Prénom » plutôt
|
||||
// que « firstName »), et NE RÉVÈLE PAS la valeur d'un champ mot de passe
|
||||
// (le secret ne doit pas repartir dans le contexte/l'export).
|
||||
const cdpHelpersJS = `
|
||||
function ajRole(el){var r=el.getAttribute('role');if(r)return r;var t=el.tagName.toLowerCase();if(t==='a')return 'link';if(t==='button')return 'button';if(t==='select')return 'combobox';if(t==='textarea')return 'textbox';if(t==='input'){var ty=(el.type||'text').toLowerCase();var m={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',reset:'button',file:'button',range:'slider',password:'password',search:'searchbox'};return m[ty]||'textbox';}return t;}
|
||||
function ajName(el){var t=el.tagName.toLowerCase();var lbl=el.getAttribute('aria-label')||'';if(!lbl){var lb=el.getAttribute('aria-labelledby');if(lb){var e2=document.getElementById(lb.split(' ')[0]);if(e2)lbl=e2.innerText||'';}}if(!lbl&&el.labels&&el.labels.length)lbl=el.labels[0].innerText||'';if(!lbl)lbl=el.getAttribute('placeholder')||el.getAttribute('title')||el.getAttribute('alt')||'';var val='';if((t==='input'||t==='textarea')&&el.type!=='password'&&el.type!=='hidden')val=el.value||'';var n=val?(lbl?(lbl+' = '+val):val):lbl;if(!n&&t!=='input'&&t!=='select'&&t!=='textarea')n=el.innerText||'';if(!n){var c=el.querySelector('img[alt],[aria-label],[title],svg title');if(c)n=c.getAttribute&&(c.getAttribute('alt')||c.getAttribute('aria-label')||c.getAttribute('title'))||c.textContent||'';}if(!n)n=el.getAttribute('name')||el.getAttribute('value')||'';return (n||'').replace(/\s+/g,' ').trim().slice(0,100);}
|
||||
function ajActionable(el){var t=el.tagName.toLowerCase();if(t==='a')return !!el.getAttribute('href');if(t==='button'||t==='input'||t==='select'||t==='textarea'||t==='summary')return el.type!=='hidden';if(el.isContentEditable)return true;if(el.getAttribute('onclick'))return true;var r=(el.getAttribute('role')||'').toLowerCase();return {button:1,link:1,checkbox:1,radio:1,tab:1,menuitem:1,menuitemcheckbox:1,menuitemradio:1,option:1,'switch':1,combobox:1,textbox:1,searchbox:1,slider:1,spinbutton:1}[r]===1;}
|
||||
function ajVis(el){var r=el.getBoundingClientRect();if(r.width<3||r.height<3)return false;var s=getComputedStyle(el);if(s.visibility==='hidden'||s.display==='none'||s.opacity==='0')return false;if(r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)return false;return true;}
|
||||
var ajSel='a[href],button,input,select,textarea,[role],[onclick],[tabindex],summary,[contenteditable="true"]';
|
||||
// Traversée du SHADOW DOM : les bandeaux cookies et composants web (usercentrics,
|
||||
// cookiebot…) vivent dans des shadow roots que querySelectorAll ne voit pas → le
|
||||
// modèle ne pouvait ni les lister ni les cliquer (test Lady Sushi). ajQueryAll /
|
||||
// ajByRef / ajClear descendent récursivement dans chaque shadowRoot ouvert.
|
||||
function ajQueryAll(){var out=[];function w(r){var e=r.querySelectorAll(ajSel);for(var i=0;i<e.length;i++)out.push(e[i]);var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);return out;}
|
||||
function ajByRef(ref){function f(r){var e=r.querySelector('[data-loki-ref="'+ref+'"]');if(e)return e;var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++)if(a[i].shadowRoot){var x=f(a[i].shadowRoot);if(x)return x;}return null;}return f(document);}
|
||||
function ajClear(){function w(r){var o=r.querySelectorAll('[data-loki-ref]');for(var i=0;i<o.length;i++)o[i].removeAttribute('data-loki-ref');var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);}
|
||||
`
|
||||
|
||||
// cdpSnapshotJS marque chaque élément ACTIONNABLE et VISIBLE d'un attribut
|
||||
// data-loki-ref numéroté, mémorise le centre de chacun dans window.__lokiRefs,
|
||||
// et renvoie {title,url,refs,above,below}. Compact volontairement.
|
||||
const cdpSnapshotJS = cdpHelpersJS + `(function(){
|
||||
ajClear();
|
||||
var els=ajQueryAll().filter(function(el){return ajActionable(el)&&ajVis(el);});
|
||||
var map={},refs=[],i=0;
|
||||
for(var k=0;k<els.length&&i<200;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
i++;var r=el.getBoundingClientRect();map[i]={x:r.left+r.width/2,y:r.top+r.height/2};
|
||||
el.setAttribute('data-loki-ref',String(i));
|
||||
refs.push('['+i+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
window.__lokiRefs=map;
|
||||
var de=document.documentElement;
|
||||
var sh=Math.max(de.scrollHeight,document.body?document.body.scrollHeight:0);
|
||||
return {title:document.title||'',url:location.href,refs:refs,
|
||||
above:Math.round(scrollY),below:Math.round(Math.max(0,sh-scrollY-innerHeight))};
|
||||
})()`
|
||||
|
||||
type cuSnap struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Refs []string `json:"refs"`
|
||||
Above int `json:"above"` // pixels défilables au-dessus de la vue
|
||||
Below int `json:"below"` // pixels défilables en dessous de la vue
|
||||
}
|
||||
|
||||
// snapshot lit l'arbre interactif courant et le formate pour le modèle.
|
||||
func (s *cdpSession) snapshot() (string, error) {
|
||||
val, err := s.eval(cdpSnapshotJS)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var snap cuSnap
|
||||
if err := json.Unmarshal(val, &snap); err != nil {
|
||||
return "", fmt.Errorf("snapshot illisible: %v", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n%s\n\n", snap.Title, snap.URL)
|
||||
if len(snap.Refs) == 0 {
|
||||
b.WriteString("(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%d élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n", len(snap.Refs))
|
||||
b.WriteString(strings.Join(snap.Refs, "\n"))
|
||||
}
|
||||
// Indice de défilement : la liste ne couvre QUE la partie visible. Sans ce
|
||||
// repère, le modèle croit la page épuisée après un scroll (3 éléments restants)
|
||||
// et se met à deviner des URLs au lieu de défiler (vécu sur le test IONOS).
|
||||
if snap.Below > 20 || snap.Above > 20 {
|
||||
b.WriteString("\n\n")
|
||||
if snap.Below > 20 {
|
||||
fmt.Fprintf(&b, "↓ encore ~%d px sous la vue — browser_scroll(down) pour révéler d'autres éléments. ", snap.Below)
|
||||
}
|
||||
if snap.Above > 20 {
|
||||
fmt.Fprintf(&b, "↑ ~%d px au-dessus — browser_scroll(up) pour y revenir.", snap.Above)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), " "), nil
|
||||
}
|
||||
|
||||
// snapshotDedup renvoie le snapshot courant, ou une ligne courte s'il est
|
||||
// RIGOUREUSEMENT identique au dernier renvoyé (clic sans effet, ré-ouverture de
|
||||
// la même page…). Évite de re-lister 20+ éléments pour rien — les numéros restent
|
||||
// valides puisque le DOM n'a pas changé. Économie de contexte pour petits modèles.
|
||||
func (s *cdpSession) snapshotDedup() (string, error) {
|
||||
full, err := s.snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if full == s.lastSnap {
|
||||
return "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", nil
|
||||
}
|
||||
s.lastSnap = full
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// cdpFindJS cherche, sur TOUTE la page (pas seulement le viewport), les éléments
|
||||
// interactifs dont le rôle ou le nom contient la requête, leur pose un
|
||||
// data-loki-ref numéroté, fait défiler le premier au centre, et renvoie la liste.
|
||||
// Sert à localiser un lien/bouton hors écran sans défiler à l'aveugle.
|
||||
// browser_find est NON DESTRUCTIF (contrairement au snapshot) : il n'efface PAS les
|
||||
// data-loki-ref existants — sinon les numéros du dernier snapshot deviendraient
|
||||
// invalides et un browser_click ensuite échouerait (« introuvable », vécu au test #11).
|
||||
// Il RÉUTILISE le ref d'un élément déjà numéroté, et n'attribue un NOUVEAU numéro
|
||||
// (au-delà du max courant) qu'aux éléments hors écran encore sans ref.
|
||||
const cdpFindJS = `function(q){
|
||||
q=(q||'').toLowerCase();
|
||||
var pool=ajQueryAll();
|
||||
var maxRef=0;for(var m=0;m<pool.length;m++){var v=parseInt(pool[m].getAttribute('data-loki-ref'),10)||0;if(v>maxRef)maxRef=v;}
|
||||
var els=pool.filter(ajActionable);
|
||||
var refs=[],count=0,first=null;
|
||||
for(var k=0;k<els.length&&count<60;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
if((rl+' '+nm).toLowerCase().indexOf(q)<0)continue;
|
||||
var st=getComputedStyle(el);if(st.display==='none'||st.visibility==='hidden')continue;
|
||||
var r=el.getBoundingClientRect();if(r.width<1&&r.height<1)continue;
|
||||
var ref=el.getAttribute('data-loki-ref');
|
||||
if(!ref){maxRef++;ref=String(maxRef);el.setAttribute('data-loki-ref',ref);}
|
||||
if(!first)first=el;count++;
|
||||
refs.push('['+ref+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
if(first)first.scrollIntoView({block:'center'});
|
||||
return {refs:refs};
|
||||
}`
|
||||
|
||||
func (s *cdpSession) find(query string) (string, error) {
|
||||
qb, _ := json.Marshal(query)
|
||||
val, err := s.eval(cdpHelpersJS + "(" + cdpFindJS + ")(" + string(qb) + ")")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var r struct {
|
||||
Refs []string `json:"refs"`
|
||||
}
|
||||
if err := json.Unmarshal(val, &r); err != nil {
|
||||
return "", fmt.Errorf("recherche illisible: %v", err)
|
||||
}
|
||||
if len(r.Refs) == 0 {
|
||||
return fmt.Sprintf("Aucun élément contenant « %s » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien).", query), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d élément(s) contenant « %s » (le 1er est amené dans la vue) — clique par numéro :\n", len(r.Refs), query)
|
||||
b.WriteString(strings.Join(r.Refs, "\n"))
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ─── actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// waitLoad attend (au mieux) que la page ait fini de charger après une navigation,
|
||||
// puis que le DOM se STABILISE (contenu rendu en AJAX après readyState=complete).
|
||||
func (s *cdpSession) waitLoad() {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
val, err := s.eval(`document.readyState`)
|
||||
if err == nil && strings.Trim(string(val), `"`) == "complete" {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
s.settle()
|
||||
}
|
||||
|
||||
// settle attend l'INACTIVITÉ RÉSEAU : sur une SPA, readyState passe à « complete »
|
||||
// avant que le contenu chargé en AJAX (résultats de recherche, étape de tunnel…)
|
||||
// ne soit arrivé. On rend la main dès qu'il n'y a plus de requête en vol depuis
|
||||
// ~600 ms, borné à 6 s. Ça supprime le « bash sleep » que le modèle bricolait
|
||||
// avant un browser_snapshot (vécu au test IONOS #10).
|
||||
func (s *cdpSession) settle() {
|
||||
const quiet = 600 * time.Millisecond
|
||||
deadline := time.Now().Add(6 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.netMu.Lock()
|
||||
inflight := s.inflight
|
||||
since := time.Since(s.lastNet)
|
||||
s.netMu.Unlock()
|
||||
if inflight <= 0 && since >= quiet {
|
||||
return
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) navigate(url string) error {
|
||||
// Toute navigation = nouvelle page : on oublie le dernier snapshot pour que le
|
||||
// browser_open qui suit renvoie la page COMPLÈTE, jamais « (page inchangée) »
|
||||
// (piège quand la session persiste d'un test à l'autre, vécu sur Lady Sushi).
|
||||
s.lastSnap = ""
|
||||
// « back » / « forward » : navigation dans l'historique (récupération d'erreur),
|
||||
// pas une URL — sinon on en ferait « https://back ».
|
||||
switch strings.ToLower(strings.TrimSpace(url)) {
|
||||
case "back":
|
||||
_, err := s.eval("history.back();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
case "forward":
|
||||
_, err := s.eval("history.forward();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(url, "://") {
|
||||
url = "https://" + url
|
||||
}
|
||||
res, err := s.call("Page.navigate", map[string]any{"url": url})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Page.navigate signale l'échec réseau dans errorText (DNS, connexion refusée…) ;
|
||||
// et une page d'erreur Chrome a une location « chrome-error:// ». Dans les deux
|
||||
// cas on renvoie une erreur EXPLICITE plutôt qu'un faux snapshot « Actualiser »
|
||||
// que le modèle re-tentait en boucle (vécu : account.ionos.com ré-ouvert 2×).
|
||||
var nr struct {
|
||||
ErrorText string `json:"errorText"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &nr)
|
||||
s.waitLoad()
|
||||
loc, _ := s.eval("location.href")
|
||||
// ERR_ABORTED est bénin (redirection/téléchargement) : on ne le traite pas comme
|
||||
// un échec. Le signal fiable est une location « chrome-error:// ».
|
||||
failed := strings.Contains(string(loc), "chrome-error")
|
||||
if nr.ErrorText != "" && nr.ErrorText != "net::ERR_ABORTED" {
|
||||
failed = true
|
||||
}
|
||||
if failed {
|
||||
reason := nr.ErrorText
|
||||
if reason == "" {
|
||||
reason = "page injoignable"
|
||||
}
|
||||
return fmt.Errorf("chargement de %s échoué (%s) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", url, reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refCenter renvoie les coordonnées viewport (fraîches) d'un élément marqué, en
|
||||
// le faisant d'abord défiler au centre.
|
||||
func (s *cdpSession) refCenter(ref int) (float64, float64, error) {
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(){var el=ajByRef(%d);if(!el){return null;}el.scrollIntoView({block:'center',inline:'center'});var r=el.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`, ref)
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if string(val) == "null" || len(val) == 0 {
|
||||
return 0, 0, fmt.Errorf("élément [%d] introuvable — refais un browser_snapshot (la page a peut-être changé)", ref)
|
||||
}
|
||||
var p struct{ X, Y float64 }
|
||||
if err := json.Unmarshal(val, &p); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return p.X, p.Y, nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) clickRef(ref int) error {
|
||||
x, y, err := s.refCenter(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clickXY clique à des coordonnées pixel du VIEWPORT (lues sur le screenshot).
|
||||
// Seul moyen d'atteindre ce que le DOM ne voit pas : un bandeau de consentement
|
||||
// dans un iframe cross-origin, un canvas, une carte (vécu sur Lady Sushi).
|
||||
func (s *cdpSession) clickXY(x, y float64) error {
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) typeText(text string) error {
|
||||
_, err := s.call("Input.insertText", map[string]any{"text": text})
|
||||
return err
|
||||
}
|
||||
|
||||
// typeInto écrit dans le champ [ref]. Cas spécial des listes déroulantes NATIVES
|
||||
// (<select>, rôle « combobox ») : leur menu s'ouvre en natif hors du DOM, donc
|
||||
// ses options ne sont pas cliquables dans le snapshot (galère du test IONOS #14).
|
||||
// Ici on SÉLECTIONNE directement l'option dont le texte contient la valeur, et on
|
||||
// émet input/change. Sinon on focus+sélectionne le contenu et on laisse insertText
|
||||
// remplacer (comportement texte normal).
|
||||
func (s *cdpSession) typeInto(ref int, text string) error {
|
||||
vb, _ := json.Marshal(text)
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(ref,val){
|
||||
var el=ajByRef(ref);
|
||||
if(!el)return 'NOTFOUND';
|
||||
el.scrollIntoView({block:'center'});
|
||||
if(el.tagName.toLowerCase()==='select'){
|
||||
var o=el.options,lv=val.toLowerCase(),k=-1;
|
||||
for(var i=0;i<o.length;i++){var tx=(o[i].text||'').toLowerCase();if(tx===lv||tx.indexOf(lv)>=0||(o[i].value||'').toLowerCase()===lv){k=i;break;}}
|
||||
if(k<0)return 'NOOPT';
|
||||
el.selectedIndex=k;el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
return 'SELECT';
|
||||
}
|
||||
el.focus();
|
||||
try{if(typeof el.select==='function')el.select();else if(window.getSelection){var r=document.createRange();r.selectNodeContents(el);var s=getSelection();s.removeAllRanges();s.addRange(r);}}catch(e){}
|
||||
return 'TEXT';
|
||||
})(%d,%s)`, ref, string(vb))
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.Trim(string(val), `"`) {
|
||||
case "NOTFOUND":
|
||||
return fmt.Errorf("champ [%d] introuvable — refais un browser_snapshot", ref)
|
||||
case "NOOPT":
|
||||
return fmt.Errorf("aucune option de la liste [%d] ne correspond à « %s » — fais un browser_screenshot pour voir les choix", ref, text)
|
||||
case "SELECT":
|
||||
return nil // liste déroulante réglée, rien à taper
|
||||
default: // TEXT
|
||||
return s.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpKeys mappe les touches nommées vers leur description CDP.
|
||||
var cdpKeys = map[string]struct {
|
||||
key, code string
|
||||
vk int
|
||||
}{
|
||||
"enter": {"Enter", "Enter", 13},
|
||||
"tab": {"Tab", "Tab", 9},
|
||||
"backspace": {"Backspace", "Backspace", 8},
|
||||
"escape": {"Escape", "Escape", 27},
|
||||
"esc": {"Escape", "Escape", 27},
|
||||
"delete": {"Delete", "Delete", 46},
|
||||
"up": {"ArrowUp", "ArrowUp", 38},
|
||||
"down": {"ArrowDown", "ArrowDown", 40},
|
||||
"left": {"ArrowLeft", "ArrowLeft", 37},
|
||||
"right": {"ArrowRight", "ArrowRight", 39},
|
||||
"home": {"Home", "Home", 36},
|
||||
"end": {"End", "End", 35},
|
||||
"pageup": {"PageUp", "PageUp", 33},
|
||||
"pagedown": {"PageDown", "PageDown", 34},
|
||||
}
|
||||
|
||||
func (s *cdpSession) pressKey(name string) error {
|
||||
k, ok := cdpKeys[strings.ToLower(strings.TrimSpace(name))]
|
||||
if !ok {
|
||||
return fmt.Errorf("touche inconnue « %s » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown", name)
|
||||
}
|
||||
for _, typ := range []string{"rawKeyDown", "keyUp"} {
|
||||
if _, err := s.call("Input.dispatchKeyEvent", map[string]any{
|
||||
"type": typ, "key": k.key, "code": k.code,
|
||||
"windowsVirtualKeyCode": k.vk, "nativeVirtualKeyCode": k.vk,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) scroll(dir string) error {
|
||||
dy, dx := 0, 0
|
||||
switch strings.ToLower(strings.TrimSpace(dir)) {
|
||||
case "down", "":
|
||||
dy = 600
|
||||
case "up":
|
||||
dy = -600
|
||||
case "right":
|
||||
dx = 600
|
||||
case "left":
|
||||
dx = -600
|
||||
default:
|
||||
return fmt.Errorf("direction inconnue « %s » (up, down, left, right)", dir)
|
||||
}
|
||||
_, err := s.eval(fmt.Sprintf("window.scrollBy(%d,%d);'ok'", dx, dy))
|
||||
if err == nil {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// screenshot capture le viewport en PNG (octets décodés).
|
||||
func (s *cdpSession) screenshot() ([]byte, error) {
|
||||
res, err := s.call("Page.captureScreenshot", map[string]any{"format": "png"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(r.Data)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCDPSmoke pilote un vrai Chrome de bout en bout : lancement, navigation,
|
||||
// snapshot d'accessibilité, click, screenshot. Coûteux et dépendant d'un
|
||||
// navigateur installé → ne tourne QUE si LOKI_CU_SMOKE=1.
|
||||
func TestCDPSmoke(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if !strings.Contains(strings.ToLower(snap), "example") {
|
||||
t.Errorf("titre 'example' attendu dans le snapshot, absent")
|
||||
}
|
||||
if !strings.Contains(snap, "link") {
|
||||
t.Errorf("le lien 'More information' devrait apparaître comme [n] link")
|
||||
}
|
||||
// browser_find doit retrouver le lien par son texte.
|
||||
found, err := s.find("more")
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(found), "link") {
|
||||
t.Errorf("browser_find('more') devrait trouver le lien, got: %s", found)
|
||||
}
|
||||
// Le screenshot doit produire des octets PNG non vides.
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
t.Fatalf("screenshot: %v", err)
|
||||
}
|
||||
if len(png) < 100 {
|
||||
t.Errorf("screenshot suspicieusement petit: %d octets", len(png))
|
||||
}
|
||||
t.Logf("screenshot: %d octets", len(png))
|
||||
}
|
||||
|
||||
// TestCDPTypeReplacesField vérifie le fix du test IONOS #4 : browser_type(ref) cible le
|
||||
// BON champ et REMPLACE son contenu, même avec deux champs et une valeur existante.
|
||||
func TestCDPTypeReplacesField(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Deux champs texte injectés dans la page courante ; le 2e contient « ancien ».
|
||||
if _, err := s.eval(`document.body.innerHTML='<input id=a placeholder=prenom><input id=b value=ancien>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Retrouve le numéro du 2e champ (celui à remplacer).
|
||||
val, err := s.eval(`document.querySelector('#b').getAttribute('data-loki-ref')`)
|
||||
if err != nil {
|
||||
t.Fatalf("eval ref: %v", err)
|
||||
}
|
||||
ref, _ := strconv.Atoi(strings.Trim(string(val), `"`))
|
||||
if ref == 0 {
|
||||
t.Fatalf("pas de ref sur #b (snapshot: %s)", val)
|
||||
}
|
||||
if err := s.typeInto(ref, "nouveau"); err != nil {
|
||||
t.Fatalf("typeInto: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#b').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "nouveau" {
|
||||
t.Errorf("champ #b = %q, attendu \"nouveau\" (remplacement raté)", v)
|
||||
}
|
||||
// Le premier champ ne doit PAS avoir été touché.
|
||||
got2, _ := s.eval(`document.querySelector('#a').value`)
|
||||
if v := strings.Trim(string(got2), `"`); v != "" {
|
||||
t.Errorf("champ #a = %q, attendu vide (mauvais ciblage)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotHygiene vérifie que le snapshot (1) masque la valeur d'un champ
|
||||
// mot de passe et (2) ne liste pas les conteneurs non-actionnables (un <ul>/<li>).
|
||||
func TestCDPSnapshotHygiene(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
inject := `document.body.innerHTML='` +
|
||||
`<label for=pw>Mot de passe</label><input id=pw type=password value=SECRET123>` +
|
||||
`<label for=em>Email</label><input id=em type=text value="a@b.co">` +
|
||||
`<ul><li>bloc conteneur</li></ul>` +
|
||||
`<a href="#x">un lien</a>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if strings.Contains(snap, "SECRET123") {
|
||||
t.Error("la valeur du champ mot de passe ne doit PAS apparaître dans le snapshot")
|
||||
}
|
||||
if strings.Contains(snap, "bloc conteneur") {
|
||||
t.Error("un <li> conteneur ne doit pas figurer dans la liste des éléments actionnables")
|
||||
}
|
||||
if !strings.Contains(snap, "un lien") {
|
||||
t.Error("le lien actionnable devrait figurer dans le snapshot")
|
||||
}
|
||||
// Le champ mot de passe lui-même reste listé (par son label), juste sans valeur.
|
||||
if !strings.Contains(strings.ToLower(snap), "mot de passe") {
|
||||
t.Error("le champ mot de passe devrait apparaître par son label")
|
||||
}
|
||||
// Un champ texte non-secret DOIT montrer sa valeur (observabilité du formulaire).
|
||||
if !strings.Contains(snap, "a@b.co") {
|
||||
t.Error("la valeur d'un champ texte non-secret devrait apparaître dans le snapshot")
|
||||
}
|
||||
// Un <input type=text> doit être annoncé « textbox », pas « text » (qui ressemble
|
||||
// à du texte statique et faisait croire au modèle que c'était une étiquette).
|
||||
if !strings.Contains(snap, "textbox") {
|
||||
t.Error("un champ de saisie devrait avoir le rôle 'textbox'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPRefUniqueness reproduit le bug du test IONOS #9 : un snapshot doit
|
||||
// EFFACER les anciens data-loki-ref, sinon deux éléments partagent un numéro et
|
||||
// browser_type/browser_click visent le mauvais champ (querySelector prend le 1er du DOM).
|
||||
func TestCDPRefUniqueness(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>A</a><a href=#>B</a><a href=#>C</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Simule un attribut périmé laissé par un snapshot antérieur sur un AUTRE élément.
|
||||
if _, err := s.eval(`document.querySelectorAll('a')[2].setAttribute('data-loki-ref','1');'ok'`); err != nil {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
// Un nouveau snapshot doit repartir propre : chaque numéro sur UN SEUL élément.
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot2: %v", err)
|
||||
}
|
||||
dup, _ := s.eval(`document.querySelectorAll('[data-loki-ref="1"]').length`)
|
||||
if strings.TrimSpace(string(dup)) != "1" {
|
||||
t.Errorf("data-loki-ref=\"1\" devrait être unique, trouvé %s occurrence(s)", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSettleWaitsAsync vérifie que settle() attend le contenu injecté en
|
||||
// asynchrone (cas du test #10 où le modèle devait « bash sleep » avant snapshot).
|
||||
func TestCDPSettleWaitsAsync(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Page réelle (réseau réel), puis on déclenche un fetch AJAX qui n'ajoute son
|
||||
// lien qu'une fois la requête terminée. settle() doit attendre l'inactivité
|
||||
// réseau et donc voir le lien.
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
if _, err := s.eval(`fetch(location.href,{cache:'no-store'}).then(function(){var a=document.createElement('a');a.href='#';a.textContent='AjaxDone';document.body.appendChild(a);});'ok'`); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond) // laisse requestWillBeSent s'enregistrer
|
||||
s.settle() // doit attendre la fin du fetch
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "AjaxDone") {
|
||||
t.Errorf("settle() aurait dû attendre la fin du fetch AJAX; snapshot:\n%s", snap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPTypeSelectsOption vérifie le fix du test #14 : browser_type sur un <select>
|
||||
// choisit l'option par son texte au lieu de taper des caractères.
|
||||
func TestCDPTypeSelectsOption(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<select id=civ><option value="">--</option><option>Madame</option><option>Monsieur</option><option>Autre</option></select>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
ref, _ := s.eval(`+document.querySelector('#civ').getAttribute('data-loki-ref')`)
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(string(ref)))
|
||||
if n == 0 {
|
||||
t.Fatalf("pas de ref sur le select (%s)", ref)
|
||||
}
|
||||
if err := s.typeInto(n, "Monsieur"); err != nil {
|
||||
t.Fatalf("typeInto select: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#civ').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "Monsieur" {
|
||||
t.Errorf("le select devrait valoir « Monsieur », got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotDedup vérifie qu'un état identique renvoie une ligne courte au
|
||||
// lieu de re-lister tous les éléments (économie de contexte, test #12).
|
||||
func TestCDPSnapshotDedup(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Un lien</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
first, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup1: %v", err)
|
||||
}
|
||||
if !strings.Contains(first, "Un lien") {
|
||||
t.Fatalf("1er snapshot devrait lister le lien, got: %s", first)
|
||||
}
|
||||
second, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup2: %v", err)
|
||||
}
|
||||
if strings.Contains(second, "Un lien") || !strings.Contains(second, "inchang") {
|
||||
t.Errorf("2e snapshot identique devrait être compact (inchangé), got: %s", second)
|
||||
}
|
||||
// Après un vrai changement, on redonne la liste complète.
|
||||
if _, err := s.eval(`document.body.innerHTML+='<a href=#>Autre</a>';'ok'`); err != nil {
|
||||
t.Fatalf("change: %v", err)
|
||||
}
|
||||
third, _ := s.snapshotDedup()
|
||||
if !strings.Contains(third, "Autre") {
|
||||
t.Errorf("après changement, le snapshot complet devrait revenir, got: %s", third)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPFindKeepsRefs vérifie le fix du test #11 : browser_find ne doit PAS effacer
|
||||
// les data-loki-ref du dernier snapshot (sinon un browser_click ensuite échoue).
|
||||
func TestCDPFindKeepsRefs(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Alpha</a><a href=#>Beta</a><a href=#>Connexion</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
before, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
// Recherche d'un AUTRE élément : ne doit pas toucher au ref de « Connexion ».
|
||||
if _, err := s.find("Alpha"); err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
after, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
if string(before) != string(after) || strings.Trim(string(after), `"`) == "" {
|
||||
t.Errorf("browser_find a modifié/effacé le ref de « Connexion » : avant=%s après=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClickXY vérifie le clic par coordonnées (pour iframe cross-origin/canvas).
|
||||
func TestCDPClickXY(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un bouton à une position connue qui note son propre clic.
|
||||
inject := `document.body.innerHTML='<button id=b style="position:fixed;left:40px;top:30px;width:120px;height:40px">X</button>';` +
|
||||
`window.__hit=false;document.getElementById('b').addEventListener('click',function(){window.__hit=true;});'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if err := s.clickXY(100, 50); err != nil { // dans le rectangle du bouton
|
||||
t.Fatalf("clickXY: %v", err)
|
||||
}
|
||||
hit, _ := s.eval(`window.__hit`)
|
||||
if strings.TrimSpace(string(hit)) != "true" {
|
||||
t.Errorf("le clic par coordonnées aurait dû atteindre le bouton, got %s", hit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPShadowDOM vérifie le fix du test Lady Sushi : les éléments d'un shadow
|
||||
// DOM (bandeaux cookies, web components) sont vus par le snapshot ET cliquables.
|
||||
func TestCDPShadowDOM(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un host avec un bouton « TOUT REFUSER » DANS un shadow root ouvert.
|
||||
inject := `document.body.innerHTML='<div id=host></div>';` +
|
||||
`var sr=document.getElementById('host').attachShadow({mode:'open'});` +
|
||||
`sr.innerHTML='<button id=b>TOUT REFUSER</button>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "TOUT REFUSER") {
|
||||
t.Fatalf("le bouton du shadow DOM devrait apparaître dans le snapshot; got:\n%s", snap)
|
||||
}
|
||||
// browser_find doit aussi le trouver, et browser_click le viser (via ajByRef).
|
||||
found, err := s.find("refuser")
|
||||
if err != nil || !strings.Contains(strings.ToLower(found), "refuser") {
|
||||
t.Errorf("find n'a pas trouvé le bouton shadow: %v / %s", err, found)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPNavigateError vérifie qu'une URL injoignable renvoie une ERREUR claire
|
||||
// (et pas un faux snapshot avec un bouton « Actualiser » que le modèle re-tente).
|
||||
func TestCDPNavigateError(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
err = s.navigate("https://nonexistent-host-loki-test-99999.invalid")
|
||||
if err == nil {
|
||||
t.Error("une URL injoignable devrait renvoyer une erreur, pas nil")
|
||||
} else {
|
||||
t.Logf("erreur attendue: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestDedupableTool verrouille le fix du test IONOS : les outils à état vivant /
|
||||
// à image (cu_*, see_image, bash) ne doivent JAMAIS être dédupliqués, sinon un
|
||||
// second appel identique renvoie « [déjà fait] » sans rejouer l'action ni
|
||||
// l'image, et l'IA boucle.
|
||||
func TestDedupableTool(t *testing.T) {
|
||||
never := []string{"bash", "see_image", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_key", "browser_scroll", "browser_screenshot"}
|
||||
for _, n := range never {
|
||||
if dedupableTool(n) {
|
||||
t.Errorf("%s ne doit PAS être déduplicable", n)
|
||||
}
|
||||
}
|
||||
always := []string{"write", "edit", "mem_add", "web_open"}
|
||||
for _, n := range always {
|
||||
if !dedupableTool(n) {
|
||||
t.Errorf("%s devrait rester déduplicable", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package loki
|
||||
|
||||
// computer_use.go — le « computer use » : donner à Jean la capacité d'agir sur
|
||||
// une interface graphique de la machine hôte. Étape 1 : le WEB, via un Chromium
|
||||
// piloté en CDP (computer_cdp.go). Le modèle lit l'arbre d'accessibilité (des
|
||||
// éléments interactifs NUMÉROTÉS, façon set-of-marks) et agit par numéro —
|
||||
// aucune vision requise, ce qui marche même avec de petits modèles texte à
|
||||
// faible contexte. browser_screenshot n'est proposé que si la vision est active, pour
|
||||
// les cas sans a11y (canvas, jeux).
|
||||
//
|
||||
// C'est un axe de capacité indépendant, sous le mode agent (comme l'accès
|
||||
// internet) : un outil qui pilote un navigateur exécute des actions réelles, donc
|
||||
// même niveau de confiance que bash. Interrupteur : clé bkState "computer".
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func computerUseEnabled() bool { return getBool(bkState, "computer") }
|
||||
|
||||
func setComputerUseEnabled(on bool) error { return putBool(bkState, "computer", on) }
|
||||
|
||||
// cuMaxOutput borne ce qu'un appel d'outil computer use injecte dans le contexte
|
||||
// (aligné sur le web et le shell : 8000).
|
||||
const cuMaxOutput = 8000
|
||||
|
||||
func capCUOutput(s string) string {
|
||||
if r := []rune(s); len(r) > cuMaxOutput {
|
||||
return string(r[:cuMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── schémas d'outils (annoncés au modèle) ───────────────────────────────────
|
||||
|
||||
func cuOpenTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_open",
|
||||
Description: "Open a URL in the controlled browser and return the page's interactive elements, each numbered [n]. Start here. Pass 'back' or 'forward' instead of a URL to move through history.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"url": map[string]any{"type": "string", "description": "URL to open, or 'back' / 'forward'"}},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuSnapshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_snapshot",
|
||||
Description: "Re-read the current page's interactive elements (numbered). Use it after the page changes to get fresh [n] refs.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click",
|
||||
Description: "Click the element with the given number from the latest snapshot. Returns the page state afterwards.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"ref": map[string]any{"type": "integer", "description": "Element number [n]"}},
|
||||
"required": []string{"ref"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuTypeTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_type",
|
||||
Description: "Type text into a field. ALWAYS give 'ref' (the field's number) so it targets the right field and REPLACES its current content — no need to browser_click the field first. For a dropdown/select (role 'combobox'), pass the option's text and it picks that option directly (don't click it open). Without a ref it types into whatever is focused, which is unreliable.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to type"},
|
||||
"ref": map[string]any{"type": "integer", "description": "Optional: field number [n] to focus first"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuKeyTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_key",
|
||||
Description: "Press a special key: enter, tab, backspace, escape, delete, up, down, left, right, home, end, pageup, pagedown.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"key": map[string]any{"type": "string", "description": "Key name"}},
|
||||
"required": []string{"key"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuFindTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_find",
|
||||
Description: "Find interactive elements ANYWHERE on the page (not just the visible part) whose label contains the text, and scroll the first into view. Use it to locate an off-screen link or button (e.g. 'créer un compte', 'panier') instead of scrolling blindly or guessing URLs.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"text": map[string]any{"type": "string", "description": "Text to look for in element labels"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScrollTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_scroll",
|
||||
Description: "Scroll the page (up, down, left, right), then return the now-visible interactive elements.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"direction": map[string]any{"type": "string", "description": "up, down, left or right"}},
|
||||
"required": []string{"direction"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickXYTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click_xy",
|
||||
Description: fmt.Sprintf("Click at pixel coordinates (x from the left, y from the top) read off the latest browser_screenshot — %dx%d pixels, 1:1 with the page, with a coordinate grid drawn every 100px to read x/y precisely. Use it ONLY for something the numbered elements can't reach: a cookie/consent banner inside a cross-origin iframe, a canvas, a map. Otherwise always prefer browser_click(ref).", cuViewW, cuViewH),
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"x": map[string]any{"type": "number", "description": "pixels from the left edge"},
|
||||
"y": map[string]any{"type": "number", "description": "pixels from the top edge"},
|
||||
},
|
||||
"required": []string{"x", "y"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScreenshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_screenshot",
|
||||
Description: "Capture the current page as an image. It is shown to the user immediately AND saved to your working folder; the result gives the filename to link with Markdown if they want the file. Use it to see a visual layout the numbered elements don't convey, or when the user asks for a screenshot — never hunt the disk for it.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
// computerUseTools est la liste annoncée au modèle quand le computer use est
|
||||
// actif. browser_screenshot n'y figure que si la vision est réellement active.
|
||||
func computerUseTools() []Tool {
|
||||
tools := []Tool{cuOpenTool(), cuSnapshotTool(), cuFindTool(), cuClickTool(), cuTypeTool(), cuKeyTool(), cuScrollTool()}
|
||||
// Vision active : la capture d'écran ET le clic par coordonnées (qui n'a de sens
|
||||
// que si le modèle peut lire l'image pour viser).
|
||||
if visionEnabled() {
|
||||
tools = append(tools, cuScreenshotTool(), cuClickXYTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// cuPromptLine est la consigne d'usage ajoutée au prompt système quand le
|
||||
// computer use est actif (l'ordre d'appel que les schémas isolés ne disent pas).
|
||||
func cuPromptLine() string {
|
||||
line := "\nComputer use (a browser you control on this machine): browser_open a URL, then act on the NUMBERED elements — browser_click(ref), browser_type(text, ref), browser_key, browser_scroll. Each action returns the fresh numbered elements; after the page changes, trust the new numbers, not old ones. The list shows only what's in view: to reach an off-screen link or button (e.g. 'créer un compte'), use browser_find(text) to jump straight to it rather than scrolling blindly or, worse, guessing URLs — inventing paths mostly 404s. If a page shows an error (404, 'introuvable'), browser_open goes 'back' or try another link. Prefer the numbered elements over screenshots."
|
||||
if visionEnabled() {
|
||||
line += " Use browser_screenshot when the numbers aren't enough (canvas, visual layout). If a button is visible but NOT in the numbered list (e.g. a cookie banner inside an iframe), browser_screenshot then browser_click_xy(x,y) at its pixel position."
|
||||
}
|
||||
line += "\n"
|
||||
return line
|
||||
}
|
||||
|
||||
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
|
||||
|
||||
func toolCUOpen(args map[string]any) string {
|
||||
url, _ := args["url"].(string)
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return "[erreur] url manquante"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.navigate(url); err != nil {
|
||||
return "[erreur] navigation : " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUSnapshot() string {
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUFind(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "[erreur] texte de recherche manquant"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
res, err := s.find(text)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func toolCUClick(args map[string]any) string {
|
||||
ref, ok := intArg(args, "ref")
|
||||
if !ok {
|
||||
return "[erreur] ref manquant (numéro de l'élément)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickRef(ref); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] cliqué [%d]\n\n%s", ref, snap)
|
||||
}
|
||||
|
||||
func toolCUType(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if ref, ok := intArg(args, "ref"); ok {
|
||||
if err := s.typeInto(ref, text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
} else if err := s.typeText(text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] saisi « %s »\n\n%s", text, snap)
|
||||
}
|
||||
|
||||
func toolCUKey(args map[string]any) string {
|
||||
key, _ := args["key"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.pressKey(key); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] touche %s\n\n%s", key, snap)
|
||||
}
|
||||
|
||||
func toolCUClickXY(args map[string]any) string {
|
||||
x, okx := floatArg(args, "x")
|
||||
y, oky := floatArg(args, "y")
|
||||
if !okx || !oky {
|
||||
return "[erreur] coordonnées x et y requises (en pixels du screenshot)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickXY(x, y); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] clic en (%.0f, %.0f)\n\n%s", x, y, snap)
|
||||
}
|
||||
|
||||
func toolCUScroll(args map[string]any) string {
|
||||
dir, _ := args["direction"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.scroll(dir); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return snap
|
||||
}
|
||||
|
||||
// toolCUScreenshot renvoie (accusé texte, partie image_url) comme see_image :
|
||||
// l'image est réinjectée dans un message utilisateur multimodal par l'appelant.
|
||||
//
|
||||
// La capture PLEINE RÉSOLUTION est aussi ENREGISTRÉE dans le dossier de travail :
|
||||
// sans ça, le modèle qui reçoit « envoie-moi une capture » partait chercher un
|
||||
// PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour
|
||||
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
|
||||
func toolCUScreenshot() (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
// Enregistre le PNG plein format dans le workspace (chemin RELATIF renvoyé au
|
||||
// modèle : un lien Markdown [capture](nom.png) est servi par /api/chat/file).
|
||||
saved := ""
|
||||
name := "capture-" + time.Now().Format("20060102-150405") + ".png"
|
||||
if ws := agentWorkspace(); ws != "" {
|
||||
if err := os.WriteFile(filepath.Join(ws, name), png, 0o644); err == nil {
|
||||
saved = name
|
||||
}
|
||||
}
|
||||
// L'image du MODÈLE porte une GRILLE de coordonnées (lignes graduées tous les
|
||||
// 100 px) pour qu'il lise x/y avec précision ; celle sauvegardée pour l'utilisateur
|
||||
// (plus haut, `png`) reste PROPRE, sans grille.
|
||||
b, mime := prepareImageForModel(overlayGrid(png), "image/png")
|
||||
// ⚠️ TOI (le modèle) vois l'image ci-dessous, mais l'utilisateur NE la voit PAS
|
||||
// automatiquement dans le chat. Pour la lui afficher, il faut l'insérer en IMAGE
|
||||
// Markdown  — un simple lien [texte](fichier) ne fait qu'un bouton
|
||||
// de téléchargement, pas d'aperçu.
|
||||
msg := fmt.Sprintf("[ok] capture prise (tu la vois ci-dessous, %dx%d px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille.", cuViewW, cuViewH)
|
||||
if saved != "" {
|
||||
msg += " Enregistrée sous « " + saved + " ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : . Un simple lien [capture](" + saved + ") ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci."
|
||||
}
|
||||
return msg, imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// intArg extrait un entier d'un argument d'outil (le JSON décode les nombres en
|
||||
// float64).
|
||||
func intArg(args map[string]any, key string) (int, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// floatArg extrait un nombre d'un argument d'outil (coordonnées de clic).
|
||||
func floatArg(args map[string]any, key string) (float64, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ─── CLI : loki computer [on|off|status] ────────────────────────────────────
|
||||
|
||||
func cmdComputer(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
if err := setComputerUseEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := " contrôle du navigateur activé — l'IA pilote un navigateur (browser_open/browser_click/browser_type/…) si le mode agent est actif"
|
||||
if chromePath() == "" {
|
||||
msg += "\n" + red("[!]") + " aucun Chrome/Chromium/Edge détecté — installe-le ou définis LOKI_CHROME=<chemin>"
|
||||
}
|
||||
fmt.Println(green("[ok]") + msg)
|
||||
case "off":
|
||||
if err := setComputerUseEnabled(false); err != nil {
|
||||
return err
|
||||
}
|
||||
cdpShutdown()
|
||||
fmt.Println(green("[ok]") + " contrôle du navigateur désactivé")
|
||||
case "", "status":
|
||||
state := dim("off")
|
||||
if computerUseEnabled() {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Contrôle du navigateur"), state)
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
fmt.Printf(" navigateur : %s — installe Chrome/Chromium/Edge ou LOKI_CHROME=<chemin>\n", red("introuvable"))
|
||||
} else {
|
||||
fmt.Printf(" navigateur : %s\n", bold(bin))
|
||||
}
|
||||
fmt.Printf(" outils : browser_open, browser_snapshot, browser_find, browser_click, browser_type, browser_key, browser_scroll")
|
||||
if visionEnabled() {
|
||||
fmt.Printf(", browser_screenshot, browser_click_xy")
|
||||
}
|
||||
fmt.Println()
|
||||
default:
|
||||
return fmt.Errorf("usage: loki computer [on|off|status]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le contexte de la requête suivante = prompt + généré − raisonnement (jamais
|
||||
// renvoyé), sans jamais retirer plus que ce qui a été généré.
|
||||
func TestStatsCtxAfter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
prompt, gen, reasonN int
|
||||
want int
|
||||
}{
|
||||
{"sans raisonnement", 40000, 500, 0, 40500},
|
||||
{"raisonnement retiré", 40000, 6000, 5000, 41000},
|
||||
{"tout est raisonnement", 40000, 3000, 3000, 40000},
|
||||
{"compte de morceaux au-delà du généré : borné", 40000, 2000, 2500, 40000},
|
||||
{"pas d'usage", 0, 300, 100, 200},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s := StatsEvent{PromptTokensTotal: c.prompt, GenTokens: c.gen, ReasoningTokens: c.reasonN}
|
||||
if got := s.ctxAfter(); got != c.want {
|
||||
t.Errorf("%s : ctxAfter = %d, attendu %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Garde de marge : à 65 k comme à 131 k, plancher et marge seuls ne devancent
|
||||
// jamais le seuil de 75 % ; seule une génération récente longue l'avance.
|
||||
func TestGenHeadroomShort(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
used, peak, window int
|
||||
want bool
|
||||
ratioAlreadyTriggers bool
|
||||
}{
|
||||
{"65k, inconnu", 60000, 0, 65536, false, true},
|
||||
{"65k, courte : sous le seuil, pas d'avance", 49000, 2000, 65536, false, false},
|
||||
{"65k, courte : plancher 8k + marge, sous 84 %", 55000, 2000, 65536, false, true},
|
||||
{"65k, longue : 1,2×15k ne tient plus", 47000, 15000, 65536, true, false},
|
||||
{"65k, longue mais la place y est", 40000, 15000, 65536, false, false},
|
||||
{"65k, énorme (45k) mais historique tout juste compacté", 20000, 45000, 65536, false, false},
|
||||
{"65k, énorme (45k) au-delà de la moitié", 33000, 45000, 65536, true, false},
|
||||
{"131k, courte : rien avant le seuil", 98000, 4000, 131072, false, false},
|
||||
{"131k, très longue génération", 95000, 30000, 131072, true, false},
|
||||
{"8k, plancher ramené à 1/8", 6000, 0, 8192, false, false},
|
||||
{"contexte inconnu", 0, 30000, 65536, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := genHeadroomShort(c.used, c.peak, c.window); got != c.want {
|
||||
t.Errorf("%s : genHeadroomShort = %v, attendu %v", c.name, got, c.want)
|
||||
}
|
||||
ratio := c.used >= int(float64(c.window)*compactTriggerFrac)
|
||||
if ratio != c.ratioAlreadyTriggers {
|
||||
t.Errorf("%s : cas mal posé, seuil de 75 %% = %v", c.name, ratio)
|
||||
}
|
||||
}
|
||||
// Sans génération longue, la garde ne part jamais avant le seuil de 75 %.
|
||||
for _, w := range []int{4096, 8192, 16384, 32768, 65536, 131072, 262144} {
|
||||
below := int(float64(w)*compactTriggerFrac) - 1
|
||||
if genHeadroomShort(below, 1, w) {
|
||||
t.Errorf("fenêtre %d : la garde devance le seuil sans génération longue", w)
|
||||
}
|
||||
// Génération aussi longue que la fenêtre : jamais avant la moitié, sinon
|
||||
// l'historique fraîchement compacté la redéclenche à chaque étape.
|
||||
if genHeadroomShort(w/2, w, w) {
|
||||
t.Errorf("fenêtre %d : la garde part à 50 %% (compaction en boucle)", w)
|
||||
}
|
||||
if !genHeadroomShort(w/2+1, w, w) {
|
||||
t.Errorf("fenêtre %d : la garde ne part pas au-delà de 50 %% avec une génération énorme", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le message arrivé depuis la dernière mesure s'ajoute ; une mesure inconnue
|
||||
// ou qui ne correspond plus à l'historique n'ajoute rien.
|
||||
func TestCtxPending(t *testing.T) {
|
||||
msgs := []Message{um("bonjour"), am("salut"), um(strings.Repeat("x", 4000))}
|
||||
added := estimateTokens(msgs[2:])
|
||||
cases := []struct {
|
||||
name string
|
||||
used, measured int
|
||||
want int
|
||||
}{
|
||||
{"nouveau message estimé", 30000, 2, 30000 + added},
|
||||
{"rien de nouveau", 30000, 3, 30000},
|
||||
{"mesure inconnue (rechargement)", 30000, 0, 30000},
|
||||
{"historique raccourci depuis", 30000, 5, 30000},
|
||||
{"aucun compte : repli sur l'estimation complète, ailleurs", 0, 2, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ctxPending(c.used, c.measured, msgs); got != c.want {
|
||||
t.Errorf("%s : ctxPending = %d, attendu %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fauxMoteurScript : llama-server de comédie qui sert ses réponses streamées
|
||||
// dans l'ordre (la dernière se répète), et un résumé JSON aux appels non
|
||||
// streamés (compaction). Garde le dernier message de chaque requête streamée.
|
||||
func fauxMoteurScript(t *testing.T, streams ...string) (port string, lastMsgs func() []string) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var last []string
|
||||
n := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Stream bool `json:"stream"`
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if !body.Stream {
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé des étapes précédentes"}}}})
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
i := min(n, len(streams)-1)
|
||||
n++
|
||||
if len(body.Messages) > 0 {
|
||||
last = append(last, msgText(body.Messages[len(body.Messages)-1]))
|
||||
}
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(streams[i] + "data: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
return u.Port(), func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]string(nil), last...)
|
||||
}
|
||||
}
|
||||
|
||||
func sseReasoning(text string) string {
|
||||
return `data: {"choices":[{"delta":{"reasoning_content":"` + text + `"}}]}` + "\n\n"
|
||||
}
|
||||
|
||||
func sseFinal(finish string, prompt, gen int) string {
|
||||
return `data: {"choices":[{"delta":{},"finish_reason":"` + finish + `"}]}` + "\n\n" +
|
||||
`data: {"choices":[],"timings":{"prompt_n":10,"predicted_n":` + itoa(gen) + `},"usage":{"prompt_tokens":` + itoa(prompt) + `,"completion_tokens":` + itoa(gen) + `}}` + "\n\n"
|
||||
}
|
||||
|
||||
// runForStats lance runChat et rend le dernier StatsEvent reçu.
|
||||
func runForStats(t *testing.T, msgs []Message, caps Caps) (StatsEvent, string) {
|
||||
t.Helper()
|
||||
var st StatsEvent
|
||||
var content strings.Builder
|
||||
if _, err := runChat(context.Background(), msgs, 0.7, caps, func(ev StreamEvent) bool {
|
||||
if ev.Stats != nil {
|
||||
st = *ev.Stats
|
||||
}
|
||||
content.WriteString(ev.Content)
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st, content.String()
|
||||
}
|
||||
|
||||
// Raisonnement séparé par le moteur : compté, et retiré du contexte suivant.
|
||||
func TestRaisonnementSepareCompte(t *testing.T) {
|
||||
testHome(t)
|
||||
port, _ := fauxMoteurScript(t, sseReasoning("je ")+sseReasoning("réfléchis ")+sseReasoning("ici")+
|
||||
sseChunk("Bon")+sseChunk("jour")+sseFinal("stop", 100, 7))
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ := runForStats(t, []Message{um("salut")}, Caps{})
|
||||
if st.ReasoningTokens != 3 {
|
||||
t.Fatalf("ReasoningTokens = %d, attendu 3", st.ReasoningTokens)
|
||||
}
|
||||
if got := st.ctxAfter(); got != 104 {
|
||||
t.Fatalf("ctxAfter = %d, attendu 104 (100 + 7 − 3)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// <think> en ligne découpé chez nous : ce texte reste dans le message renvoyé
|
||||
// au modèle pendant le tour, il ne doit donc PAS être retiré du compte.
|
||||
func TestRaisonnementEnLigneNonCompte(t *testing.T) {
|
||||
testHome(t)
|
||||
port, _ := fauxMoteurScript(t, sseChunk("je pense")+sseChunk("</think>")+sseChunk("Réponse")+sseFinal("stop", 100, 6))
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetConfigKey("REASONING", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sawReasoning bool
|
||||
var st StatsEvent
|
||||
if _, err := runChat(context.Background(), []Message{um("salut")}, 0.7, Caps{}, func(ev StreamEvent) bool {
|
||||
if ev.Reasoning != "" {
|
||||
sawReasoning = true
|
||||
}
|
||||
if ev.Stats != nil {
|
||||
st = *ev.Stats
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sawReasoning {
|
||||
t.Fatal("le <think> en ligne n'a pas été découpé : test mal posé")
|
||||
}
|
||||
if st.ReasoningTokens != 0 || st.ctxAfter() != 106 {
|
||||
t.Fatalf("raisonnement en ligne compté : ReasoningTokens=%d ctxAfter=%d", st.ReasoningTokens, st.ctxAfter())
|
||||
}
|
||||
}
|
||||
|
||||
// Une relance (ici le nudge « pensé sans agir ») repart d'un compte vierge :
|
||||
// le raisonnement d'une tentative jetée n'est pas retiré deux fois.
|
||||
func TestRaisonnementRemisAZeroALaRelance(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
port, _ := fauxMoteurScript(t,
|
||||
sseReasoning("a")+sseReasoning("b")+sseReasoning("c")+sseFinal("stop", 100, 3),
|
||||
sseReasoning("d")+sseChunk("Fait.")+sseFinal("stop", 120, 4))
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, content := runForStats(t, []Message{um("fais-le")}, Caps{Agent: true})
|
||||
if content != "Fait." {
|
||||
t.Fatalf("réponse = %q, la relance n'a pas eu lieu", content)
|
||||
}
|
||||
if st.ReasoningTokens != 1 || st.ctxAfter() != 123 {
|
||||
t.Fatalf("après relance : ReasoningTokens=%d ctxAfter=%d, attendu 1 et 123", st.ReasoningTokens, st.ctxAfter())
|
||||
}
|
||||
}
|
||||
|
||||
// Fenêtre pleine en plein raisonnement (« length ») : on compacte et on rejoue
|
||||
// l'étape, sans le nudge qui demanderait au modèle d'arrêter de raisonner.
|
||||
func TestFenetrePleineCompacteEtRejoue(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
if err := SetConfigKey("CTX", "4096"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, lastMsgs := fauxMoteurScript(t,
|
||||
sseReasoning("long")+sseReasoning(" raisonnement")+sseFinal("length", 3900, 196),
|
||||
sseChunk("Réponse.")+sseFinal("stop", 1500, 3))
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := []Message{um("corrige le build"), am("ok"), um("continue")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("bash"), tm("sortie "+strings.Repeat("x", 400)))
|
||||
}
|
||||
msgs = append(msgs, um("et maintenant ?"))
|
||||
var compacted, dropped bool
|
||||
var content strings.Builder
|
||||
if _, err := runChat(context.Background(), msgs, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
|
||||
if ev.NewHistory != nil {
|
||||
compacted = true
|
||||
}
|
||||
if ev.DropReasoning {
|
||||
dropped = true
|
||||
}
|
||||
content.WriteString(ev.Content)
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !compacted || !dropped {
|
||||
t.Fatalf("fenêtre pleine : compacté=%v raisonnement retiré=%v", compacted, dropped)
|
||||
}
|
||||
if content.String() != "Réponse." {
|
||||
t.Fatalf("réponse = %q", content.String())
|
||||
}
|
||||
for _, m := range lastMsgs() {
|
||||
if strings.Contains(m, "You reasoned but did not") || strings.Contains(m, "Stop reasoning") {
|
||||
t.Fatalf("nudge envoyé malgré la fenêtre pleine : %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La passe du vérificateur tourne sur sa propre trace : ses comptes ne
|
||||
// touchent ni CtxUsed ni la jauge, contrairement à une passe de correction.
|
||||
func TestPasseIsoleeNeTouchePasLeContexte(t *testing.T) {
|
||||
testHome(t)
|
||||
c := newTestConv()
|
||||
c.CtxUsed = 45000
|
||||
st := &StatsEvent{PromptTokensTotal: 3000, GenTokens: 800, ReasoningTokens: 500}
|
||||
c.forwardStream(StreamEvent{Stats: st}, c.epoch, true)
|
||||
if c.CtxUsed != 45000 {
|
||||
t.Fatalf("passe isolée : CtxUsed = %d, attendu 45000 inchangé", c.CtxUsed)
|
||||
}
|
||||
if last := c.Log[len(c.Log)-1].Delta; last["ctx_isolated"] != true {
|
||||
t.Fatalf("passe isolée : la jauge n'est pas prévenue (%v)", last)
|
||||
}
|
||||
c.forwardStream(StreamEvent{Stats: st}, c.epoch, false)
|
||||
if c.CtxUsed != 3300 {
|
||||
t.Fatalf("passe de correction : CtxUsed = %d, attendu 3300 (3000 + 800 − 500)", c.CtxUsed)
|
||||
}
|
||||
if c.genPeak != 800 {
|
||||
t.Fatalf("passe de correction : genPeak = %d, attendu 800", c.genPeak)
|
||||
}
|
||||
}
|
||||
|
||||
// Preset externe : le compte mesuré seul, comme avant ; local : complété des
|
||||
// messages arrivés depuis la mesure.
|
||||
func TestCtxNowLockedExternal(t *testing.T) {
|
||||
msgs := []Message{um("bonjour"), am("salut"), um(strings.Repeat("x", 4000))}
|
||||
c := &Conversation{CtxUsed: 30000, ctxUsedLen: 2}
|
||||
if got := c.ctxNowLocked(msgs, false); got != 30000 {
|
||||
t.Errorf("externe : %d, attendu le compte mesuré seul (30000)", got)
|
||||
}
|
||||
if got, want := c.ctxNowLocked(msgs, true), 30000+estimateTokens(msgs[2:]); got != want {
|
||||
t.Errorf("local : %d, attendu %d", got, want)
|
||||
}
|
||||
}
|
||||
+937
-109
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,238 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Le benchmark tourne en TÂCHE DE FOND. Le mode complet dure de une à cinq
|
||||
// minutes : une requête HTTP synchrone était coupée bien avant par un reverse
|
||||
// proxy (nginx d'Unraid à 60 s, Cloudflare à 100 s) ou par le délai de
|
||||
// l'interface (30 s), pendant que le moteur, lui, continuait — et un second
|
||||
// clic empilait un deuxième bench derrière le premier. Désormais :
|
||||
//
|
||||
// POST /api/bench lance (202 + id), 409 si le moteur est occupé
|
||||
// GET /api/bench/status phase en cours, puis résultat ou erreur
|
||||
// POST /api/bench/cancel annule (chaque requête au moteur porte le contexte)
|
||||
// GET /api/bench/last dernier résultat enregistré
|
||||
//
|
||||
// Pendant toute la mesure, le bench tient le verrou de génération de la
|
||||
// conversation : un message, une tâche planifiée ou une compaction reçoivent
|
||||
// un refus clair au lieu d'attendre des minutes dans la file du moteur. Les
|
||||
// clients /v1 reçoivent un 503 avec Retry-After (llm_oai.go). Restent hors du
|
||||
// verrou les appels annexes déclenchés par la fin d'un tour (titre, mémoire) :
|
||||
// le verrou les précède de toute façon.
|
||||
|
||||
var errBenchBusy = errors.New("un benchmark occupe le moteur — attends sa fin ou annule-le")
|
||||
|
||||
// benchLease prend le verrou de génération pour un benchmark. cancel est
|
||||
// branché sur le bouton stop du chat, comme pour une tâche planifiée. La
|
||||
// fonction rendue le libère tant que benching dit que le verrou est encore au
|
||||
// bench : un Reset l'a peut-être déjà rendu (benching remis à faux), et le tour
|
||||
// qui a pu démarrer depuis ne doit pas être déclaré libre. L'epoch ne suffit
|
||||
// pas : changer de discussion le bumpe SANS arrêter le bench (loadFrom).
|
||||
func (c *Conversation) benchLease(cancel context.CancelFunc) (func(), error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.Generating {
|
||||
return nil, ErrBusy
|
||||
}
|
||||
c.Generating, c.benching, c.cancel = true, true, cancel
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
c.mu.Lock()
|
||||
if c.benching {
|
||||
c.Generating, c.benching, c.cancel = false, false, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
// benchJob : le benchmark en cours ou le dernier terminé (un seul à la fois).
|
||||
var benchJob struct {
|
||||
mu sync.Mutex
|
||||
seq int
|
||||
running bool
|
||||
id string
|
||||
mode string
|
||||
phase string
|
||||
step int
|
||||
steps int
|
||||
started time.Time
|
||||
finished time.Time
|
||||
cancel context.CancelFunc
|
||||
result *benchResult
|
||||
err string
|
||||
canceled bool
|
||||
}
|
||||
|
||||
// benchRunning : un benchmark de ce processus est-il en cours ?
|
||||
func benchRunning() bool {
|
||||
benchJob.mu.Lock()
|
||||
defer benchJob.mu.Unlock()
|
||||
return benchJob.running
|
||||
}
|
||||
|
||||
// benchRunner est runBench, remplaçable dans les tests.
|
||||
var benchRunner = runBench
|
||||
|
||||
// benchStart lance un benchmark en arrière-plan. code : le statut HTTP à
|
||||
// renvoyer en cas de refus.
|
||||
func benchStart(opts benchOpts) (id string, code int, err error) {
|
||||
// Hors verrou : healthCheck peut attendre le moteur quelques secondes, le
|
||||
// suivi de progression ne doit pas rester bloqué derrière.
|
||||
if externalActive() {
|
||||
return "", http.StatusBadRequest, errors.New("benchmark indisponible : le preset actif est une API externe")
|
||||
}
|
||||
if !healthCheck() {
|
||||
return "", http.StatusServiceUnavailable, errModelLoading
|
||||
}
|
||||
benchJob.mu.Lock()
|
||||
defer benchJob.mu.Unlock()
|
||||
if benchJob.running {
|
||||
return "", http.StatusConflict, errors.New("un benchmark tourne déjà")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
release, err := conv.benchLease(cancel)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return "", http.StatusConflict, conv.busyReason()
|
||||
}
|
||||
benchJob.seq++
|
||||
id = fmt.Sprintf("b%d-%d", time.Now().Unix(), benchJob.seq)
|
||||
benchJob.running, benchJob.id, benchJob.mode = true, id, opts.Mode
|
||||
benchJob.phase, benchJob.step, benchJob.steps = "démarrage", 0, 0
|
||||
benchJob.started, benchJob.finished = time.Now(), time.Time{}
|
||||
benchJob.cancel, benchJob.result, benchJob.err, benchJob.canceled = cancel, nil, "", false
|
||||
go func() {
|
||||
defer cancel()
|
||||
res, err := benchRunner(ctx, opts, func(phase string, step, steps int) {
|
||||
benchJob.mu.Lock()
|
||||
if benchJob.id == id {
|
||||
benchJob.phase, benchJob.step, benchJob.steps = phase, step, steps
|
||||
}
|
||||
benchJob.mu.Unlock()
|
||||
})
|
||||
canceled := ctx.Err() != nil
|
||||
// Le verrou du chat est rendu AVANT d'annoncer la fin : l'interface qui
|
||||
// lit « terminé » et envoie aussitôt un message ne doit pas se voir
|
||||
// refuser « benchmark en cours ».
|
||||
release()
|
||||
benchJob.mu.Lock()
|
||||
defer benchJob.mu.Unlock()
|
||||
if benchJob.id != id {
|
||||
return
|
||||
}
|
||||
benchJob.running, benchJob.finished, benchJob.cancel = false, time.Now(), nil
|
||||
benchJob.result = res
|
||||
if err != nil {
|
||||
benchJob.canceled = canceled
|
||||
benchJob.err = err.Error()
|
||||
if canceled {
|
||||
benchJob.err = "benchmark annulé"
|
||||
}
|
||||
}
|
||||
}()
|
||||
return id, http.StatusAccepted, nil
|
||||
}
|
||||
|
||||
// benchCancel annule le benchmark en cours. false : aucun ne tournait.
|
||||
func benchCancel() bool {
|
||||
benchJob.mu.Lock()
|
||||
defer benchJob.mu.Unlock()
|
||||
if !benchJob.running || benchJob.cancel == nil {
|
||||
return false
|
||||
}
|
||||
benchJob.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
// benchStatus : l'état à renvoyer à l'interface.
|
||||
func benchStatus() map[string]any {
|
||||
benchJob.mu.Lock()
|
||||
defer benchJob.mu.Unlock()
|
||||
st := map[string]any{"ok": true, "running": benchJob.running, "id": benchJob.id, "mode": benchJob.mode}
|
||||
if benchJob.id == "" {
|
||||
return st
|
||||
}
|
||||
st["phase"], st["step"], st["steps"] = benchJob.phase, benchJob.step, benchJob.steps
|
||||
end := benchJob.finished
|
||||
if benchJob.running {
|
||||
end = time.Now()
|
||||
}
|
||||
st["elapsed_sec"] = end.Sub(benchJob.started).Seconds()
|
||||
if !benchJob.running {
|
||||
if benchJob.result != nil {
|
||||
st["result"] = benchJob.result
|
||||
st["saved"] = benchSavable(benchJob.result)
|
||||
}
|
||||
if benchJob.err != "" {
|
||||
st["error"] = benchJob.err
|
||||
st["canceled"] = benchJob.canceled
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// handleBench lance un benchmark : POST seulement — il occupe le moteur des
|
||||
// minutes durant, une balise <img> sur une page tierce ne doit pas y suffire.
|
||||
// Corps : {"mode":"quick"|"full", "prompt":N, "n":N}, tous facultatifs.
|
||||
func handleBench(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Mode string `json:"mode"`
|
||||
Prompt int `json:"prompt"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
opts := benchOpts{Mode: benchModeQuick, Prompt: 2000, Predict: 300}
|
||||
if strings.EqualFold(strings.TrimSpace(body.Mode), benchModeFull) {
|
||||
opts.Mode = benchModeFull
|
||||
}
|
||||
if body.Prompt > 0 {
|
||||
opts.Prompt = min(body.Prompt, 32768)
|
||||
}
|
||||
if body.N > 0 {
|
||||
opts.Predict = min(body.N, 4096)
|
||||
}
|
||||
id, code, err := benchStart(opts)
|
||||
if err != nil {
|
||||
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, code, map[string]any{"ok": true, "id": id})
|
||||
}
|
||||
|
||||
// handleBenchStatus : progression du benchmark en cours, ou issue du dernier.
|
||||
func handleBenchStatus(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, benchStatus())
|
||||
}
|
||||
|
||||
// handleBenchCancel annule le benchmark en cours.
|
||||
func handleBenchCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": benchCancel()})
|
||||
}
|
||||
|
||||
// handleBenchLast returns the most recent persisted benchmark, or {ok:false}
|
||||
// when none has been run yet.
|
||||
func handleBenchLast(w http.ResponseWriter, r *http.Request) {
|
||||
sb := loadLastBench()
|
||||
if sb == nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Un bench n'est affiché que pour le modèle sur lequel il a été mesuré.
|
||||
func TestBenchMatchesPreset(t *testing.T) {
|
||||
sb := savedBench{Model: "Qwen3-27B-Q4_K_M.gguf"}
|
||||
if !benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Qwen3-27B-Q4_K_M.gguf"}, nil) {
|
||||
t.Fatal("même modèle : le bench doit s'afficher")
|
||||
}
|
||||
if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Autre.gguf"}, nil) {
|
||||
t.Fatal("autre modèle : le bench ne doit pas s'afficher")
|
||||
}
|
||||
if benchMatchesPreset(sb, map[string]string{}, nil) {
|
||||
t.Fatal("preset sans modèle : pas de bench")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// fakeBenchEngine simule llama-server pour le protocole du bench : un compte
|
||||
// de jetons à 4 octets par jeton, un cache qui reprend le prompt précédent
|
||||
// quand cache_prompt est vrai, et le journal des requêtes de chat reçues.
|
||||
type fakeBenchEngine struct {
|
||||
mu sync.Mutex
|
||||
nCtx int
|
||||
busy bool
|
||||
failAt int // n° (1-based) de la requête de chat qui répond 500 ; 0 = aucune
|
||||
noTiming bool // réponses sans timings
|
||||
rejectEffort string // niveau de reasoning_effort que le « gabarit » refuse (500)
|
||||
slowHot time.Duration // délai des tours qui reprennent le cache
|
||||
prev int
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeBenchEngine) handler(t *testing.T) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
case "/slots":
|
||||
fmt.Fprintf(w, `[{"id":0,"is_processing":%v}]`, f.busy)
|
||||
case "/props":
|
||||
fmt.Fprintf(w, `{"build_info":"b9999-test","total_slots":1,"default_generation_settings":{"n_ctx":%d}}`, f.nCtx)
|
||||
case "/tokenize":
|
||||
var b struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
toks := make([]int, len(b.Content)/4)
|
||||
json.NewEncoder(w).Encode(map[string]any{"tokens": toks})
|
||||
case "/v1/chat/completions":
|
||||
var p map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
t.Errorf("corps illisible : %v", err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, p)
|
||||
n := len(f.calls)
|
||||
f.mu.Unlock()
|
||||
if f.failAt == n {
|
||||
http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if e, _ := p["reasoning_effort"].(string); f.rejectEffort != "" && e == f.rejectEffort {
|
||||
// Le vrai message cite la trace jinja d'abord : les niveaux acceptés
|
||||
// arrivent bien après les 300 premiers caractères.
|
||||
http.Error(w, `{"error":{"code":500,"message":"`+strings.Repeat("jinja trace ", 40)+
|
||||
`Unexpected reasoning effort `+e+`. Supported types are xhigh (default), medium, and low."}}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cache, _ := p["cache_prompt"].(bool)
|
||||
if d := f.slowHot; cache && d > 0 {
|
||||
select {
|
||||
case <-time.After(d):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
total := 0
|
||||
for _, m := range p["messages"].([]any) {
|
||||
c, _ := m.(map[string]any)["content"].(string)
|
||||
total += len(c)/4 + 4
|
||||
}
|
||||
cached := 0
|
||||
f.mu.Lock()
|
||||
if cache {
|
||||
cached = min(f.prev, total)
|
||||
}
|
||||
f.prev = total
|
||||
f.mu.Unlock()
|
||||
gen := int(p["max_tokens"].(float64))
|
||||
resp := map[string]any{
|
||||
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "réponse"}}},
|
||||
"usage": map[string]any{"prompt_tokens": total, "completion_tokens": gen},
|
||||
}
|
||||
if !f.noTiming {
|
||||
resp["timings"] = map[string]any{
|
||||
"prompt_n": total - cached, "prompt_ms": float64(total-cached) / 2, "prompt_per_second": 2000.0,
|
||||
"predicted_n": gen, "predicted_ms": float64(gen) * 20, "predicted_per_second": 50.0,
|
||||
"cache_n": cached, "draft_n": 10, "draft_n_accepted": 7,
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (f *fakeBenchEngine) start(t *testing.T) (benchEngine, *httptest.Server) {
|
||||
srv := httptest.NewServer(f.handler(t))
|
||||
t.Cleanup(srv.Close)
|
||||
return benchEngine{base: srv.URL, client: srv.Client()}, srv
|
||||
}
|
||||
|
||||
// testBenchCorpora : un code de 200 Ko sans aucune ligne répétée.
|
||||
func testBenchCorpora() benchCorpora {
|
||||
var b strings.Builder
|
||||
for i := 0; b.Len() < 200<<10; i++ {
|
||||
fmt.Fprintf(&b, "const ligne%06d = « contenu distinct %d » ;\n", i, i*7)
|
||||
}
|
||||
return benchCorpora{prose: benchCorpus, code: b.String()}
|
||||
}
|
||||
|
||||
func testBenchSetup() benchSetup {
|
||||
return benchSetupFrom(map[string]string{"TOP_K": "40", "MIN_P": "0.05", "REASONING": "off"}, nil)
|
||||
}
|
||||
|
||||
// Le protocole complet, requête par requête : préchauffage, ligne courte et
|
||||
// prefill à froid sans cache, puis trois tours qui prolongent la MÊME
|
||||
// discussion avec le cache — et l'échantillonnage du preset partout.
|
||||
func TestBenchProtocoleComplet(t *testing.T) {
|
||||
f := &fakeBenchEngine{nCtx: 16384}
|
||||
eng, _ := f.start(t)
|
||||
var phases []string
|
||||
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(),
|
||||
func(phase string, step, steps int) {
|
||||
phases = append(phases, fmt.Sprintf("%d/%d %s", step, steps, phase))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.calls) != 6 {
|
||||
t.Fatalf("%d requêtes de chat, attendu 6 (préchauffage, ligne courte, froid, 3 tours)", len(f.calls))
|
||||
}
|
||||
wantCache := []bool{false, false, false, true, true, true}
|
||||
wantMax := []float64{benchWarmGen, 300, benchGenTokens, benchGenTokens, benchGenTokens, benchGenTokens}
|
||||
wantMsgs := []int{1, 1, 1, 3, 5, 7}
|
||||
for i, p := range f.calls {
|
||||
if p["cache_prompt"] != wantCache[i] {
|
||||
t.Errorf("requête %d : cache_prompt=%v, attendu %v", i+1, p["cache_prompt"], wantCache[i])
|
||||
}
|
||||
if p["max_tokens"] != wantMax[i] {
|
||||
t.Errorf("requête %d : max_tokens=%v, attendu %v", i+1, p["max_tokens"], wantMax[i])
|
||||
}
|
||||
msgs := p["messages"].([]any)
|
||||
if len(msgs) != wantMsgs[i] {
|
||||
t.Errorf("requête %d : %d messages, attendu %d", i+1, len(msgs), wantMsgs[i])
|
||||
}
|
||||
for j, m := range msgs {
|
||||
want := "user"
|
||||
if j%2 == 1 {
|
||||
want = "assistant"
|
||||
}
|
||||
if role := m.(map[string]any)["role"]; role != want {
|
||||
t.Errorf("requête %d, message %d : rôle %v, attendu %s", i+1, j, role, want)
|
||||
}
|
||||
}
|
||||
if p["top_k"] != float64(40) || p["min_p"] != 0.05 || p["seed"] != float64(benchSeed) {
|
||||
t.Errorf("requête %d : échantillonnage du preset ou seed absents : %v", i+1, p)
|
||||
}
|
||||
if _, ok := p["ignore_eos"]; ok {
|
||||
t.Errorf("requête %d : ignore_eos envoyé — génération hors distribution", i+1)
|
||||
}
|
||||
if kw, _ := p["chat_template_kwargs"].(map[string]any); kw["enable_thinking"] != false {
|
||||
t.Errorf("requête %d : REASONING=off non transmis au gabarit : %v", i+1, p["chat_template_kwargs"])
|
||||
}
|
||||
}
|
||||
// Chaque tour apporte du code JAMAIS vu : aucun morceau ne se répète.
|
||||
seen := map[string]bool{}
|
||||
for _, m := range f.calls[5]["messages"].([]any) {
|
||||
c := m.(map[string]any)["content"].(string)
|
||||
if c != "réponse" && seen[c] {
|
||||
t.Error("un morceau de corpus est renvoyé deux fois")
|
||||
}
|
||||
seen[c] = true
|
||||
}
|
||||
if len(phases) != 6 || !strings.HasPrefix(phases[5], "6/6 ") {
|
||||
t.Errorf("progression inattendue : %v", phases)
|
||||
}
|
||||
d := res.Depth
|
||||
if d == nil || d.Skipped != "" || d.Partial != "" || d.Cold == nil || len(d.Turns) != benchTurns {
|
||||
t.Fatalf("phase en profondeur incomplète : %+v", d)
|
||||
}
|
||||
if want, _ := benchDepthFor(16384, false, 2*512+benchWarmGen); d.Target != want || d.Cold.New < want*9/10 {
|
||||
t.Errorf("profondeur : visée %d (attendu %d), froid %d jetons", d.Target, want, d.Cold.New)
|
||||
}
|
||||
if d.Reuse < 0.99 || d.ReuseNote != "" {
|
||||
t.Errorf("reprise du cache : %v %q, attendu complète", d.Reuse, d.ReuseNote)
|
||||
}
|
||||
if d.DecodePerSec != 50 || d.CachedPerSec != 2000 {
|
||||
t.Errorf("agrégats : decode %v, prefill %v", d.DecodePerSec, d.CachedPerSec)
|
||||
}
|
||||
if d.Draft == nil || d.Draft.N != 30 || d.Draft.Accepted != 21 || res.Draft == nil || res.Draft.N != 10 {
|
||||
t.Errorf("acceptation du brouillon : profondeur %+v, ligne courte %+v", d.Draft, res.Draft)
|
||||
}
|
||||
if res.Engine != "b9999-test" || res.Protocol != benchProtocol || res.Mode != benchModeFull || !benchSavable(res) {
|
||||
t.Errorf("métadonnées : %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// Le mode rapide s'arrête à la ligne courte.
|
||||
func TestBenchModeRapide(t *testing.T) {
|
||||
f := &fakeBenchEngine{nCtx: 16384}
|
||||
eng, _ := f.start(t)
|
||||
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeQuick}, testBenchSetup(), testBenchCorpora(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.calls) != 2 || res.Depth != nil || res.PredictedPerSec != 50 {
|
||||
t.Fatalf("%d requêtes, profondeur %+v, decode %v", len(f.calls), res.Depth, res.PredictedPerSec)
|
||||
}
|
||||
}
|
||||
|
||||
// Une réponse non-200 ou sans timings n'est jamais une mesure : erreur, donc
|
||||
// rien d'enregistré (runBench n'enregistre qu'un résultat sans erreur).
|
||||
func TestBenchEchecsNonEnregistres(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
f *fakeBenchEngine
|
||||
want string
|
||||
}{
|
||||
{"500 sur la ligne courte", &fakeBenchEngine{nCtx: 16384, failAt: 2}, "ligne courte"},
|
||||
{"500 sur un tour", &fakeBenchEngine{nCtx: 16384, failAt: 5}, "tour 2"},
|
||||
{"sans timings", &fakeBenchEngine{nCtx: 16384, noTiming: true}, errBenchNoTimings.Error()},
|
||||
{"moteur occupé", &fakeBenchEngine{nCtx: 16384, busy: true}, "déjà une requête"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
eng, _ := c.f.start(t)
|
||||
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
|
||||
if err == nil || res != nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("res=%v err=%v, attendu une erreur contenant %q", res, err, c.want)
|
||||
}
|
||||
if c.f.busy && len(c.f.calls) != 0 {
|
||||
t.Error("moteur occupé : aucune requête ne devait partir")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Contexte trop petit : la profondeur est sautée, la ligne courte reste.
|
||||
func TestBenchProfondeurSautee(t *testing.T) {
|
||||
f := &fakeBenchEngine{nCtx: 8192}
|
||||
eng, _ := f.start(t)
|
||||
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Depth == nil || res.Depth.Skipped == "" || len(f.calls) != 2 || !benchSavable(res) {
|
||||
t.Fatalf("profondeur %+v, %d requêtes", res.Depth, len(f.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// Un tour qui dépasse son budget rend un résultat PARTIEL, montré mais jamais
|
||||
// enregistré.
|
||||
func TestBenchBudgetPartiel(t *testing.T) {
|
||||
old := benchReqTimeout
|
||||
benchReqTimeout = 200 * time.Millisecond
|
||||
t.Cleanup(func() { benchReqTimeout = old })
|
||||
f := &fakeBenchEngine{nCtx: 16384, slowHot: 2 * time.Second}
|
||||
eng, _ := f.start(t)
|
||||
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Depth == nil || !strings.Contains(res.Depth.Partial, "tour 1") || benchSavable(res) {
|
||||
t.Fatalf("attendu un résultat partiel non enregistrable : %+v", res.Depth)
|
||||
}
|
||||
}
|
||||
|
||||
// Annulé : erreur, rien d'enregistré.
|
||||
func TestBenchAnnule(t *testing.T) {
|
||||
f := &fakeBenchEngine{nCtx: 16384, slowHot: 5 * time.Second}
|
||||
eng, _ := f.start(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(300 * time.Millisecond); cancel() }()
|
||||
res, err := benchRun(ctx, eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
|
||||
if err == nil || res != nil {
|
||||
t.Fatalf("annulation : res=%v err=%v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchDepthFor(t *testing.T) {
|
||||
warm := 2*512 + benchWarmGen
|
||||
for _, c := range []struct {
|
||||
ctx int
|
||||
cpu bool
|
||||
want int
|
||||
skipped bool
|
||||
comments string
|
||||
}{
|
||||
{131072, false, benchDepthMax, false, "grand contexte : plafond"},
|
||||
{131072, true, benchDepthCPU, false, "poids sur CPU : plafond réduit"},
|
||||
{65536, false, 32768, false, "moitié du contexte"},
|
||||
{16384, false, 16384 - benchTurns*(benchAppendTokens+benchGenTokens) - benchGenTokens - warm - benchMargin, false, "les tours doivent tenir"},
|
||||
{8192, false, 0, true, "trop petit"},
|
||||
{0, false, 0, true, "inconnu"},
|
||||
} {
|
||||
got, why := benchDepthFor(c.ctx, c.cpu, warm)
|
||||
if got != c.want || (why != "") != c.skipped {
|
||||
t.Errorf("%s : benchDepthFor(%d) = %d %q", c.comments, c.ctx, got, why)
|
||||
}
|
||||
if got > 0 && got+benchTurns*(benchAppendTokens+benchGenTokens)+benchGenTokens > c.ctx {
|
||||
t.Errorf("%s : %d + tours déborde %d", c.comments, got, c.ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchKVLabel(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
k, v, label string
|
||||
quant bool
|
||||
}{
|
||||
{"", "", "f16", false},
|
||||
{"f16", "", "f16", false},
|
||||
{"bf16", "bf16", "bf16", false},
|
||||
{"q8_0", "q8_0", "q8_0", true},
|
||||
{"q8_0", "", "q8_0/f16", true},
|
||||
{"", "q4_0", "f16/q4_0", true},
|
||||
} {
|
||||
label, quant := benchKVLabel(c.k, c.v)
|
||||
if label != c.label || quant != c.quant {
|
||||
t.Errorf("benchKVLabel(%q,%q) = %q,%v ; attendu %q,%v", c.k, c.v, label, quant, c.label, c.quant)
|
||||
}
|
||||
}
|
||||
// Le -ctk d'EXTRA_ARGS l'emporte, comme au lancement.
|
||||
s := benchSetupFrom(map[string]string{"KV_TYPE": "f16", "EXTRA_ARGS": "-ctk q8_0 -ctv q8_0 --n-cpu-moe 20"}, nil)
|
||||
if s.kv != "q8_0" || !s.kvQuant || !s.cpuPlaced {
|
||||
t.Errorf("réglages tirés du preset : %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchPayload(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
want map[string]any
|
||||
none []string
|
||||
}{
|
||||
{"échantillonnage du preset", map[string]string{"TEMP": "0.6", "TOP_P": "0.95", "TOP_K": "20"},
|
||||
map[string]any{"temperature": 0.6, "top_p": 0.95, "top_k": 20, "seed": benchSeed}, []string{"chat_template_kwargs", "reasoning_effort"}},
|
||||
{"sans réglage", map[string]string{},
|
||||
map[string]any{"temperature": 0.7, "seed": benchSeed}, []string{"top_k", "chat_template_kwargs"}},
|
||||
{"effort explicite", map[string]string{"REASONING_EFFORT": "high"},
|
||||
map[string]any{"reasoning_effort": "high"}, nil},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
testHome(t) // effortResolve lit le modèle de la configuration
|
||||
p := benchSetupFrom(c.cfg, nil).payload([]Message{{Role: "user", Content: "x"}}, 10, true)
|
||||
for k, v := range c.want {
|
||||
if fmt.Sprint(p[k]) != fmt.Sprint(v) {
|
||||
t.Errorf("%s = %v, attendu %v", k, p[k], v)
|
||||
}
|
||||
}
|
||||
for _, k := range append(c.none, "ignore_eos") {
|
||||
if _, ok := p[k]; ok {
|
||||
t.Errorf("%s ne devait pas être envoyé : %v", k, p[k])
|
||||
}
|
||||
}
|
||||
if p["cache_prompt"] != true || p["stream"] != false {
|
||||
t.Errorf("cache_prompt/stream : %v", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchSlice(t *testing.T) {
|
||||
text := strings.Repeat("ééé ligne\n", 100)
|
||||
off := 0
|
||||
for off < len(text) {
|
||||
chunk, next := benchSlice(text, off, 37)
|
||||
if chunk == "" || !utf8.ValidString(chunk) {
|
||||
t.Fatalf("morceau invalide à %d : %q", off, chunk)
|
||||
}
|
||||
if next < len(text) && !strings.HasSuffix(chunk, "\n") {
|
||||
t.Errorf("morceau coupé hors fin de ligne : %q", chunk)
|
||||
}
|
||||
off = next
|
||||
}
|
||||
if c, _ := benchSlice(text, len(text), 10); c != "" {
|
||||
t.Error("corpus épuisé : morceau vide attendu")
|
||||
}
|
||||
if got := benchLineStart("ab\ncd", 1); got != 3 {
|
||||
t.Errorf("benchLineStart = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchReuse(t *testing.T) {
|
||||
full := []benchTurn{{Cached: 1000, Expected: 1004}, {Cached: 3000, Expected: 3000}}
|
||||
if r, note := benchReuse(full, false); r < 0.99 || note != "" {
|
||||
t.Errorf("reprise complète : %v %q", r, note)
|
||||
}
|
||||
part := []benchTurn{{Cached: 512, Expected: 3000}}
|
||||
if _, note := benchReuse(part, true); !strings.Contains(note, "hybride") {
|
||||
t.Errorf("hybride : %q", note)
|
||||
}
|
||||
if _, note := benchReuse(part, false); !strings.Contains(note, "gabarit") {
|
||||
t.Errorf("non hybride : %q", note)
|
||||
}
|
||||
if r, note := benchReuse([]benchTurn{{Cached: -1, Expected: 3000}}, false); r != -1 || note == "" {
|
||||
t.Errorf("inconnu : %v %q", r, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchThrashHint(t *testing.T) {
|
||||
if h := benchThrashHint(100<<20, 256, 0); h != "" {
|
||||
t.Errorf("100 Mo / 256 jetons : pas d'indication attendue, %q", h)
|
||||
}
|
||||
if h := benchThrashHint(3<<30, 768, 60<<30); !strings.Contains(h, "thrash") {
|
||||
t.Errorf("1 Go / 256 jetons : indication attendue, %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
// Une mesure enregistrée ne s'affiche que pour la configuration et les cartes
|
||||
// sur lesquelles elle a été prise ; une mesure d'avant l'empreinte retombe sur
|
||||
// le nom du modèle.
|
||||
func TestBenchEmpreinte(t *testing.T) {
|
||||
cfg := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768", "CUDA_VISIBLE_DEVICES": "0,1"}
|
||||
res := &benchResult{PromptPerSecond: 1, Depth: &benchDepth{Hint: "possible thrash"}}
|
||||
sb := newSavedBench(res, cfg)
|
||||
if sb.Fingerprint == "" || sb.Result.Depth.Hint != "" || res.Depth.Hint == "" {
|
||||
t.Fatalf("enregistrement : %+v (l'indication ne s'enregistre pas, le résultat montré la garde)", sb)
|
||||
}
|
||||
preset := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768"}
|
||||
if !benchMatchesPreset(sb, preset, cfg) {
|
||||
t.Error("même configuration, mêmes cartes : la pastille doit s'afficher")
|
||||
}
|
||||
if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Q.gguf", "CTX": "65536"}, cfg) {
|
||||
t.Error("contexte différent : pastille masquée attendue")
|
||||
}
|
||||
if benchMatchesPreset(sb, preset, map[string]string{"CUDA_VISIBLE_DEVICES": "1,0"}) {
|
||||
t.Error("cartes dans un autre ordre : pastille masquée attendue")
|
||||
}
|
||||
own := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768", "CUDA_VISIBLE_DEVICES": "0,1"}
|
||||
if !benchMatchesPreset(sb, own, map[string]string{"CUDA_VISIBLE_DEVICES": "0"}) {
|
||||
t.Error("preset qui impose ses cartes : ce sont elles qui comptent")
|
||||
}
|
||||
legacy := savedBench{Model: "Q.gguf"}
|
||||
if !benchMatchesPreset(legacy, preset, cfg) {
|
||||
t.Error("mesure d'avant l'empreinte : repli sur le nom du modèle")
|
||||
}
|
||||
}
|
||||
|
||||
// Le lancement par l'interface : POST seulement, 409 pendant une génération,
|
||||
// puis un seul bench à la fois, qui tient le verrou du chat et s'annule.
|
||||
func TestBenchHandlers(t *testing.T) {
|
||||
testHome(t)
|
||||
f := &fakeBenchEngine{nCtx: 16384}
|
||||
_, srv := f.start(t)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := WriteConfig(map[string]string{"PORT": u.Port()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started := make(chan struct{})
|
||||
old := benchRunner
|
||||
benchRunner = func(ctx context.Context, o benchOpts, p benchProgress) (*benchResult, error) {
|
||||
p("mesure", 1, 2)
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
t.Cleanup(func() { benchRunner = old })
|
||||
|
||||
do := func(method, path, body string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
switch path {
|
||||
case "/api/bench":
|
||||
handleBench(rec, req)
|
||||
case "/api/bench/cancel":
|
||||
handleBenchCancel(rec, req)
|
||||
default:
|
||||
handleBenchStatus(rec, req)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
if rec := do(http.MethodGet, "/api/bench", ""); rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != http.MethodPost {
|
||||
t.Fatalf("GET : %d, Allow=%q", rec.Code, rec.Header().Get("Allow"))
|
||||
}
|
||||
|
||||
conv.mu.Lock()
|
||||
conv.Generating = true
|
||||
conv.mu.Unlock()
|
||||
rec := do(http.MethodPost, "/api/bench", `{"mode":"full"}`)
|
||||
conv.mu.Lock()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("pendant une génération : %d, attendu 409", rec.Code)
|
||||
}
|
||||
|
||||
if rec := do(http.MethodPost, "/api/bench", `{"mode":"full"}`); rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("lancement : %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
<-started
|
||||
if rec := do(http.MethodPost, "/api/bench", `{}`); rec.Code != http.StatusConflict {
|
||||
t.Errorf("second bench : %d, attendu 409", rec.Code)
|
||||
}
|
||||
if err := conv.StartTurn("bonjour", nil, Caps{}, 0); err != ErrBusy {
|
||||
t.Errorf("tour de chat pendant le bench : %v, attendu ErrBusy", err)
|
||||
}
|
||||
if err := conv.busyReason(); err != errBenchBusy {
|
||||
t.Errorf("motif du refus : %v", err)
|
||||
}
|
||||
if queued, err := conv.EnqueueOrStart("cid-bench", "x", nil, Caps{}, 0); queued || err == nil {
|
||||
t.Errorf("message pendant le bench : mis en file=%v err=%v, attendu un refus", queued, err)
|
||||
}
|
||||
var st map[string]any
|
||||
json.Unmarshal(do(http.MethodGet, "/api/bench/status", "").Body.Bytes(), &st)
|
||||
if st["running"] != true || st["phase"] != "mesure" || st["mode"] != benchModeFull {
|
||||
t.Errorf("statut en cours : %v", st)
|
||||
}
|
||||
if rec := do(http.MethodPost, "/api/bench/cancel", ""); rec.Code != 200 {
|
||||
t.Fatalf("annulation : %d", rec.Code)
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for benchRunning() && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
json.Unmarshal(do(http.MethodGet, "/api/bench/status", "").Body.Bytes(), &st)
|
||||
if st["running"] != false || st["canceled"] != true {
|
||||
t.Errorf("statut après annulation : %v", st)
|
||||
}
|
||||
if conv.isGenerating() {
|
||||
t.Error("verrou de génération non rendu après le bench")
|
||||
}
|
||||
}
|
||||
|
||||
// Les clients /v1 reçoivent un 503 à retenter pendant un benchmark.
|
||||
func TestBenchRefuseV1(t *testing.T) {
|
||||
benchJob.mu.Lock()
|
||||
benchJob.running = true
|
||||
benchJob.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
benchJob.mu.Lock()
|
||||
benchJob.running = false
|
||||
benchJob.mu.Unlock()
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
oaiHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}")))
|
||||
if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") == "" {
|
||||
t.Fatalf("pendant le bench : %d, Retry-After=%q", rec.Code, rec.Header().Get("Retry-After"))
|
||||
}
|
||||
}
|
||||
|
||||
// Un gabarit qui refuse le niveau de raisonnement du preset : le bench rejoue
|
||||
// une fois avec le niveau accepté, comme le chat, et le retient — au lieu
|
||||
// d'échouer sur le 500.
|
||||
func TestBenchNiveauRaisonnementRefuse(t *testing.T) {
|
||||
f := &fakeBenchEngine{nCtx: 16384, rejectEffort: "high"}
|
||||
eng, _ := f.start(t)
|
||||
s := benchSetupFrom(map[string]string{"REASONING_EFFORT": "high"}, nil)
|
||||
var learned []string
|
||||
s.learn = func(want, got string) { learned = append(learned, want+">"+got) }
|
||||
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeQuick}, s, testBenchCorpora(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("refus du niveau non rattrapé : %v", err)
|
||||
}
|
||||
if len(f.calls) != 3 || res.PredictedPerSec != 50 {
|
||||
t.Fatalf("%d requêtes (attendu 3 : refus, préchauffage rejoué, ligne courte)", len(f.calls))
|
||||
}
|
||||
for i, p := range f.calls[1:] {
|
||||
kw, _ := p["chat_template_kwargs"].(map[string]any)
|
||||
if p["reasoning_effort"] != "xhigh" || kw["reasoning_effort"] != "xhigh" {
|
||||
t.Errorf("requête %d : niveau %v / %v, attendu xhigh", i+2, p["reasoning_effort"], kw)
|
||||
}
|
||||
}
|
||||
if len(learned) != 1 || learned[0] != "high>xhigh" {
|
||||
t.Errorf("traduction retenue : %v", learned)
|
||||
}
|
||||
// Une autre erreur que ce refus n'est pas rejouée.
|
||||
f2 := &fakeBenchEngine{nCtx: 16384, failAt: 1}
|
||||
eng2, _ := f2.start(t)
|
||||
if _, err := benchRun(context.Background(), eng2, benchOpts{}, benchSetupFrom(map[string]string{"REASONING_EFFORT": "high"}, nil), testBenchCorpora(), nil); err == nil || len(f2.calls) != 1 {
|
||||
t.Errorf("500 ordinaire : err=%v, %d requêtes", err, len(f2.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// Un gros ubatch sur un petit contexte : le préchauffage reste sous le quart
|
||||
// de la fenêtre au lieu de la dépasser.
|
||||
func TestBenchPrechauffageBorne(t *testing.T) {
|
||||
f := &fakeBenchEngine{nCtx: 8192}
|
||||
eng, _ := f.start(t)
|
||||
s := benchSetupFrom(map[string]string{"UBATCH": "4096"}, nil)
|
||||
if _, err := benchRun(context.Background(), eng, benchOpts{}, s, testBenchCorpora(), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := f.calls[0]["messages"].([]any)[0].(map[string]any)["content"].(string)
|
||||
if toks := len(c) / 4; toks > 8192/4+64 {
|
||||
t.Errorf("préchauffage de %d jetons sur 8192 de contexte", toks)
|
||||
}
|
||||
}
|
||||
|
||||
// Changer de discussion n'arrête pas le bench et ne rend pas le verrou ; un
|
||||
// Reset l'arrête et le rend, et la libération tardive du bench ne déclare pas
|
||||
// libre le tour qui a démarré depuis.
|
||||
func TestBenchVerrouEtDiscussions(t *testing.T) {
|
||||
testHome(t)
|
||||
c := newTestConv()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
release, err := c.benchLease(cancel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.loadFrom("autre", nil)
|
||||
if ctx.Err() != nil || !c.isGenerating() || c.busyReason() != errBenchBusy {
|
||||
t.Fatal("changer de discussion a arrêté le bench ou rendu son verrou")
|
||||
}
|
||||
release()
|
||||
if c.isGenerating() {
|
||||
t.Fatal("verrou non rendu à la fin du bench, après un changement de discussion")
|
||||
}
|
||||
|
||||
ctx2, cancel2 := context.WithCancel(context.Background())
|
||||
defer cancel2()
|
||||
release2, err := c.benchLease(cancel2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Reset()
|
||||
if ctx2.Err() == nil || c.isGenerating() {
|
||||
t.Fatal("Reset doit arrêter le bench et rendre le verrou")
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.Generating = true // un tour démarré depuis
|
||||
c.mu.Unlock()
|
||||
release2()
|
||||
if !c.isGenerating() {
|
||||
t.Fatal("la libération tardive du bench a déclaré libre le tour suivant")
|
||||
}
|
||||
}
|
||||
@@ -89,3 +89,6 @@ func logBudget(calls, budget, sent int) {
|
||||
fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n",
|
||||
calls, budget, sent)
|
||||
}
|
||||
|
||||
// codeBudgetFactor : multiplicateur du palier en mode Code (voir runChat).
|
||||
const codeBudgetFactor = 3
|
||||
+1007
-95
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,158 @@
|
||||
package loki
|
||||
|
||||
// llm_injected.go — messages que LOKI glisse dans le fil (rappels, relances),
|
||||
// et leur place dans l'historique.
|
||||
//
|
||||
// Ces rappels partaient autrefois dans la vue du modèle SANS entrer dans
|
||||
// l'historique persistant : au tour suivant, llama-server recevait un fil où ils
|
||||
// avaient disparu, le préfixe divergeait juste avant eux et tout ce qui suivait
|
||||
// (souvent une longue boucle d'outils) était recalculé. Le modèle relisait en
|
||||
// prime une histoire qu'il n'avait jamais vue : des résultats d'outils suivis
|
||||
// d'une réponse finale, sans la consigne qui l'avait provoquée.
|
||||
//
|
||||
// Persistés tels qu'envoyés, ils ont un rôle `user` sans être des demandes de
|
||||
// l'utilisateur : tout ce qui lit « le dernier message user » comme la tâche en
|
||||
// cours (compaction, vérificateur, export, titre) doit donc les reconnaître et
|
||||
// les sauter — c'est isLokiInjected.
|
||||
|
||||
import "strings"
|
||||
|
||||
// lokiNotePrefix ouvre les rappels de loki (budget d'outils, consigne de la
|
||||
// relance après un 500) : une marque stable, lisible par le modèle comme une
|
||||
// note du système et non comme la parole de l'utilisateur.
|
||||
const lokiNotePrefix = "[system] "
|
||||
|
||||
// Relances « pensé sans agir » (voir runChat). Gardées en constantes : leur
|
||||
// texte exact sert aussi à les reconnaître dans l'historique.
|
||||
const (
|
||||
thinkNudgeFirst = "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."
|
||||
thinkNudgeStuck = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."
|
||||
)
|
||||
|
||||
// retryCorrectiveLead ouvre la consigne de relance « appel écrit en texte »
|
||||
// (code_retry.go), déjà persistée avant ce fichier.
|
||||
const retryCorrectiveLead = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n"
|
||||
|
||||
// toolsOffHint : consigne de la relance après un 500 (appel d'outil que
|
||||
// llama.cpp n'a pas su parser). Jamais persistée : elle ne vaut que pour la
|
||||
// relance qui suit.
|
||||
const toolsOffHint = "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered."
|
||||
|
||||
// budgetNudgeEnds : fins des trois rappels de budget (llm_budget.go). Le seul
|
||||
// préfixe « [system] » ne suffit pas à les reconnaître : un utilisateur peut
|
||||
// très bien coller un texte qui commence ainsi, et sa demande serait alors
|
||||
// sautée par la compaction, le vérificateur ou le titre. Le test
|
||||
// TestIsLokiInjected tient ces fins en phase avec budgetNudge.
|
||||
var budgetNudgeEnds = []string{
|
||||
"unless exactly one specific call is genuinely still missing.",
|
||||
"say so in the answer instead of investigating further.",
|
||||
"Write your final answer in this message. Do not call another tool.",
|
||||
}
|
||||
|
||||
// isLokiInjected dit si un message `user` vient de loki et non de
|
||||
// l'utilisateur.
|
||||
func isLokiInjected(m Message) bool {
|
||||
if m.Role != "user" {
|
||||
return false
|
||||
}
|
||||
s, ok := m.Content.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if s == thinkNudgeFirst || s == thinkNudgeStuck || s == lokiNotePrefix+toolsOffHint ||
|
||||
strings.HasPrefix(s, retryCorrectiveLead) {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(s, lokiNotePrefix) {
|
||||
return false
|
||||
}
|
||||
for _, end := range budgetNudgeEnds {
|
||||
if strings.HasSuffix(s, end) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dropStrayNudges retire de l'historique à persister les rappels de loki
|
||||
// restés en porte-à-faux : en toute fin (le tour a été arrêté ou a échoué avant
|
||||
// que le modèle y réponde), ou collés à un autre message `user` (un ajout en
|
||||
// cours de réponse arrivé juste derrière, ou un rappel éphémère qu'une
|
||||
// compaction en cours de tour a fait entrer dans l'historique). Gardés, ils
|
||||
// laisseraient deux `user` d'affilée au tour suivant — exception à chaque tour
|
||||
// sur les gabarits à alternance stricte (voir appendNudge). Retirés, on
|
||||
// retrouve l'historique d'avant leur persistance ; le cache ne perd que ce
|
||||
// qui suivait le rappel, comme avant. Renvoie msgs tel quel s'il n'y a rien à
|
||||
// retirer, une copie sinon.
|
||||
func dropStrayNudges(msgs []Message) []Message {
|
||||
for {
|
||||
drop := -1
|
||||
for i, m := range msgs {
|
||||
if !isLokiInjected(m) {
|
||||
continue
|
||||
}
|
||||
if i == len(msgs)-1 || msgs[i+1].Role == "user" || (i > 0 && msgs[i-1].Role == "user") {
|
||||
drop = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if drop < 0 {
|
||||
return msgs
|
||||
}
|
||||
msgs = append(append([]Message(nil), msgs[:drop]...), msgs[drop+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
// appendNudge ajoute un rappel de loki à la vue du modèle et, quand c'est sans
|
||||
// risque, à l'historique persistant — exactement tel qu'envoyé, pour que le
|
||||
// tour suivant rejoue les mêmes octets et retrouve son préfixe en cache.
|
||||
//
|
||||
// Sauf s'il suit directement un autre message `user` (le message de
|
||||
// l'utilisateur à l'étape 0, un autre rappel, un ajout en cours de réponse) :
|
||||
// les gabarits qui exigent l'alternance stricte user/assistant (Gemma,
|
||||
// Mistral) lèvent une exception sur deux `user` consécutifs. Éphémère, cette
|
||||
// paire ne coûte qu'une étape ; persistée, elle ferait échouer chaque tour
|
||||
// suivant de la conversation. Le rappel reste alors éphémère, comme avant.
|
||||
func appendNudge(messages, extra []Message, text string) ([]Message, []Message) {
|
||||
m := Message{Role: "user", Content: text}
|
||||
afterUser := len(messages) > 0 && messages[len(messages)-1].Role == "user"
|
||||
messages = append(messages, m)
|
||||
if !afterUser {
|
||||
extra = append(extra, m)
|
||||
}
|
||||
return messages, extra
|
||||
}
|
||||
|
||||
// withTrailingHint renvoie une COPIE de msgs dont le dernier message porte en
|
||||
// plus la consigne hint, à la fin. Sert à la relance après un 500 : poser la
|
||||
// consigne en tête (steerSystem) modifiait le message système, donc tout le
|
||||
// prompt était à recalculer ; à la fin, le préfixe déjà en cache reste valide.
|
||||
//
|
||||
// Ajoutée au contenu du dernier message (résultat d'outil ou message user)
|
||||
// plutôt qu'en nouveau message : deux `user` d'affilée cassent les gabarits
|
||||
// stricts, et sur Qwen3.5 un nouveau message user devient la « dernière
|
||||
// question » et fait re-rendre toute la boucle du tour. Les messages ne sont
|
||||
// jamais modifiés en place : ils appartiennent à l'historique.
|
||||
func withTrailingHint(msgs []Message, hint string) []Message {
|
||||
note := "\n\n" + lokiNotePrefix + hint
|
||||
out := append([]Message(nil), msgs...)
|
||||
if n := len(out); n > 0 && (out[n-1].Role == "tool" || out[n-1].Role == "user") {
|
||||
last := out[n-1]
|
||||
switch c := last.Content.(type) {
|
||||
case string:
|
||||
last.Content = c + note
|
||||
out[n-1] = last
|
||||
return out
|
||||
case []any:
|
||||
last.Content = append(append([]any(nil), c...), map[string]any{"type": "text", "text": note})
|
||||
out[n-1] = last
|
||||
return out
|
||||
case []map[string]any:
|
||||
last.Content = append(append([]map[string]any(nil), c...), map[string]any{"type": "text", "text": note})
|
||||
out[n-1] = last
|
||||
return out
|
||||
}
|
||||
}
|
||||
// Dernier message d'une autre forme (cas théorique) : un message à part.
|
||||
return append(out, Message{Role: "user", Content: lokiNotePrefix + hint})
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sseStop = `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"
|
||||
|
||||
const sseGlobCall = `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"g1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n" +
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\ndata: [DONE]\n\n"
|
||||
|
||||
// scriptedServer répond à la n-ième requête par steps[n] (le dernier se répète)
|
||||
// et garde le corps JSON de chaque requête. Un step "500" renvoie une erreur
|
||||
// 500, comme llama.cpp sur un appel d'outil qu'il ne sait pas parser.
|
||||
func scriptedServer(t *testing.T, steps ...string) func() []map[string]any {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
bodies = append(bodies, body)
|
||||
n := len(bodies) - 1
|
||||
mu.Unlock()
|
||||
step := steps[min(n, len(steps)-1)]
|
||||
switch step {
|
||||
case "500":
|
||||
http.Error(w, "Failed to parse tool call", http.StatusInternalServerError)
|
||||
return
|
||||
case "400":
|
||||
http.Error(w, "Unsupported param: tool_choice", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(step))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func() []map[string]any {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]map[string]any(nil), bodies...)
|
||||
}
|
||||
}
|
||||
|
||||
// reqMessages relit les messages d'une requête enregistrée.
|
||||
func reqMessages(t *testing.T, body map[string]any) []Message {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(body["messages"])
|
||||
var out []Message
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestIsLokiInjected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m Message
|
||||
want bool
|
||||
}{
|
||||
{"budget 1", um(budgetNudge(24, 24, 0)), true},
|
||||
{"budget 2", um(budgetNudge(48, 24, 1)), true},
|
||||
{"budget 3", um(budgetNudge(72, 24, 2)), true},
|
||||
{"budget suivants", um(budgetNudge(150, 24, 5)), true},
|
||||
{"consigne du 500 en message à part", um(lokiNotePrefix + toolsOffHint), true},
|
||||
{"demande qui commence comme un rappel", um(lokiNotePrefix + "réécris ce prompt système"), false},
|
||||
{"pensé sans agir", um(thinkNudgeFirst), true},
|
||||
{"pensé sans agir, bis", um(thinkNudgeStuck), true},
|
||||
{"appel écrit en texte", um(retryCorrective("<tool_call>x")), true},
|
||||
{"vraie demande", um("cherche les horaires"), false},
|
||||
{"demande qui cite un rappel", um("pourquoi « " + thinkNudgeFirst + " » ?"), false},
|
||||
{"assistant", am(lokiNotePrefix + "x"), false},
|
||||
{"user multimodal", Message{Role: "user", Content: []any{map[string]any{"type": "text", "text": lokiNotePrefix}}}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isLokiInjected(c.m); got != c.want {
|
||||
t.Errorf("%s : isLokiInjected = %v, attendu %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le rappel entre dans l'historique tel qu'envoyé, sauf derrière un autre
|
||||
// message user (gabarits à alternance stricte) où il reste éphémère.
|
||||
func TestAppendNudge(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
before []Message
|
||||
persisted bool
|
||||
}{
|
||||
{"après un résultat d'outil", []Message{um("q"), atc("glob"), tm("r")}, true},
|
||||
{"après un assistant", []Message{um("q"), am("a")}, true},
|
||||
{"après la demande", []Message{um("q")}, false},
|
||||
{"après un autre rappel", []Message{um("q"), atc("glob"), tm("r"), um(thinkNudgeFirst)}, false},
|
||||
{"fil vide", nil, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
msgs, extra := appendNudge(append([]Message(nil), c.before...), nil, thinkNudgeFirst)
|
||||
if len(msgs) != len(c.before)+1 || msgText(msgs[len(msgs)-1]) != thinkNudgeFirst {
|
||||
t.Errorf("%s : rappel absent de la vue du modèle : %+v", c.name, msgs)
|
||||
}
|
||||
if got := len(extra) == 1; got != c.persisted {
|
||||
t.Errorf("%s : persisté = %v, attendu %v", c.name, got, c.persisted)
|
||||
}
|
||||
if c.persisted && !reflect.DeepEqual(extra[0], msgs[len(msgs)-1]) {
|
||||
t.Errorf("%s : persisté %+v ≠ envoyé %+v", c.name, extra[0], msgs[len(msgs)-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTrailingHint(t *testing.T) {
|
||||
note := "\n\n" + lokiNotePrefix + "H"
|
||||
cases := []struct {
|
||||
name string
|
||||
in []Message
|
||||
want Message // dernier message attendu
|
||||
grow bool // un message ajouté plutôt qu'enrichi
|
||||
}{
|
||||
{"résultat d'outil", []Message{um("q"), atc("glob"), tm("r")}, Message{Role: "tool", ToolCallID: "c1", Content: "r" + note}, false},
|
||||
{"message user", []Message{um("q")}, um("q" + note), false},
|
||||
{"parties []any", []Message{{Role: "user", Content: []any{map[string]any{"type": "text", "text": "q"}}}},
|
||||
Message{Role: "user", Content: []any{map[string]any{"type": "text", "text": "q"}, map[string]any{"type": "text", "text": note}}}, false},
|
||||
{"parties []map", []Message{{Role: "user", Content: []map[string]any{{"type": "text", "text": "q"}}}},
|
||||
Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "q"}, {"type": "text", "text": note}}}, false},
|
||||
{"assistant en dernier", []Message{um("q"), am("a")}, um(lokiNotePrefix + "H"), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
orig, _ := json.Marshal(c.in)
|
||||
out := withTrailingHint(c.in, "H")
|
||||
if after, _ := json.Marshal(c.in); string(after) != string(orig) {
|
||||
t.Errorf("%s : l'historique d'origine a été modifié", c.name)
|
||||
}
|
||||
wantLen := len(c.in)
|
||||
if c.grow {
|
||||
wantLen++
|
||||
}
|
||||
if len(out) != wantLen {
|
||||
t.Fatalf("%s : %d messages, attendu %d", c.name, len(out), wantLen)
|
||||
}
|
||||
if !reflect.DeepEqual(out[len(out)-1], c.want) {
|
||||
t.Errorf("%s : dernier message %+v, attendu %+v", c.name, out[len(out)-1], c.want)
|
||||
}
|
||||
if !reflect.DeepEqual(out[:len(c.in)-1], c.in[:len(c.in)-1]) {
|
||||
t.Errorf("%s : le début du fil a changé", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les lecteurs de « la demande de l'utilisateur » sautent les rappels.
|
||||
func TestRappelsPasPrisPourLaDemande(t *testing.T) {
|
||||
msgs := []Message{um("corrige le build"), atc("bash"), tm("ok"), um(budgetNudge(24, 24, 0)), am("fait"),
|
||||
um("et les tests"), atc("bash"), tm("ok"), um(thinkNudgeFirst), am("fait")}
|
||||
c := &Conversation{Messages: msgs}
|
||||
if got := c.lastUserText(); got != "et les tests" {
|
||||
t.Errorf("lastUserText = %q", got)
|
||||
}
|
||||
if got := lastTurnsMessages(msgs, 1); len(got) == 0 || msgText(got[0]) != "et les tests" {
|
||||
t.Errorf("dernier échange = %+v", got)
|
||||
}
|
||||
if got := lastTurnsMessages(msgs, 2); len(got) != len(msgs) {
|
||||
t.Errorf("deux échanges : %d messages, attendu %d", len(got), len(msgs))
|
||||
}
|
||||
if got := convSummary([]Message{um(thinkNudgeFirst), um("bonjour")}); got != "bonjour" {
|
||||
t.Errorf("titre = %q", got)
|
||||
}
|
||||
if tr := renderTranscript([]Message{um(thinkNudgeFirst)}); !strings.HasPrefix(tr, "System: ") {
|
||||
t.Errorf("rappel présenté au résumeur comme parole de l'utilisateur : %q", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// Compaction après un long tour ponctué de rappels : la VRAIE demande est
|
||||
// réinjectée, qu'un rappel traîne dans le torse ou dans la queue.
|
||||
func TestCompactionReinjecteLaVraieDemandePasLeRappel(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- pages lues : horaires trouvés pour le matin, reste l'après-midi"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := func(n int) Message {
|
||||
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
||||
}
|
||||
torsoNudge, tailNudge := budgetNudge(24, 24, 0), budgetNudge(48, 24, 1)
|
||||
msgs := []Message{um("première question"), am("ok"), um("cherche les horaires du train pour Lyon")}
|
||||
for i := 0; i < 12; i++ {
|
||||
msgs = append(msgs, atc("web_read"), page(i))
|
||||
if i == 3 {
|
||||
msgs = append(msgs, um(torsoNudge))
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, um(tailNudge), atc("web_read"), page(13))
|
||||
_, tailStart := compactBounds(msgs, int(float64(estimateTokens(msgs))*compactTailFrac))
|
||||
if msgText(msgs[len(msgs)-3]) != tailNudge || tailStart > len(msgs)-3 {
|
||||
t.Fatalf("montage : le rappel de queue n'est pas dans la queue (tailStart=%d)", tailStart)
|
||||
}
|
||||
out, changed := compactMessages(t.Context(), msgs, Caps{})
|
||||
if !changed {
|
||||
t.Fatal("pas compacté")
|
||||
}
|
||||
count := func(s string) int {
|
||||
n := 0
|
||||
for _, m := range out {
|
||||
if m.Role == "user" && msgText(m) == s {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
if n := count("cherche les horaires du train pour Lyon"); n != 1 {
|
||||
t.Fatalf("demande en cours présente %d fois après compaction, attendu 1", n)
|
||||
}
|
||||
if count(torsoNudge) != 0 {
|
||||
t.Fatal("le rappel du torse a été réinjecté comme demande en cours")
|
||||
}
|
||||
if count(tailNudge) != 1 {
|
||||
t.Fatal("le rappel de la queue a disparu")
|
||||
}
|
||||
assertAlternance(t, out)
|
||||
}
|
||||
|
||||
// assertAlternance : jamais deux `user` d'affilée (gabarits stricts).
|
||||
func assertAlternance(t *testing.T, msgs []Message) {
|
||||
t.Helper()
|
||||
for i := 1; i < len(msgs); i++ {
|
||||
if msgs[i].Role == "user" && msgs[i-1].Role == "user" {
|
||||
t.Fatalf("deux user d'affilée en %d : %q puis %q", i, msgText(msgs[i-1]), msgText(msgs[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La queue protégée ne commence jamais sur un rappel : la vraie demande,
|
||||
// réinjectée juste devant elle, lui serait collée (deux user d'affilée).
|
||||
func TestCompactBoundsPasSurUnRappel(t *testing.T) {
|
||||
nudge := um(budgetNudge(24, 24, 0))
|
||||
msgs := []Message{um("cherche"), atc("web_read"), tm(strings.Repeat("x", 800)), nudge, atc("web_read"), tm("court")}
|
||||
budget := msgTokens(msgs[3]) + msgTokens(msgs[4]) + msgTokens(msgs[5])
|
||||
_, tailStart := compactBounds(msgs, budget)
|
||||
if tailStart != 1 {
|
||||
t.Fatalf("tailStart = %d, attendu 1 (l'appel d'outil avant le rappel)", tailStart)
|
||||
}
|
||||
// Sans rappel, la frontière reste celle d'avant.
|
||||
plain := []Message{um("cherche"), atc("web_read"), tm(strings.Repeat("x", 800)), um("et ensuite"), atc("web_read"), tm("court")}
|
||||
if _, ts := compactBounds(plain, msgTokens(plain[3])+msgTokens(plain[4])+msgTokens(plain[5])); ts != 3 {
|
||||
t.Fatalf("frontière sans rappel déplacée : %d", ts)
|
||||
}
|
||||
}
|
||||
|
||||
// Ce qui est persisté en fin de tour ne garde aucun rappel en porte-à-faux.
|
||||
func TestDropStrayNudges(t *testing.T) {
|
||||
n := um(thinkNudgeFirst)
|
||||
cases := []struct {
|
||||
name string
|
||||
in []Message
|
||||
want []Message
|
||||
}{
|
||||
{"rappel répondu", []Message{um("q"), atc("glob"), tm("r"), n, am("fini")}, nil},
|
||||
{"rappel resté sans réponse (stop)", []Message{um("q"), atc("glob"), tm("r"), n},
|
||||
[]Message{um("q"), atc("glob"), tm("r")}},
|
||||
{"ajout en cours de réponse juste derrière", []Message{um("q"), atc("glob"), tm("r"), n, um("et aussi"), am("fini")},
|
||||
[]Message{um("q"), atc("glob"), tm("r"), um("et aussi"), am("fini")}},
|
||||
{"rappel collé derrière la demande", []Message{um("q"), n, am("fini")}, []Message{um("q"), am("fini")}},
|
||||
{"correctif sans réponse", []Message{um("q"), am("<tool_call>"), um(retryCorrective("<tool_call>"))},
|
||||
[]Message{um("q"), am("<tool_call>")}},
|
||||
{"deux rappels de suite en fin", []Message{um("q"), atc("glob"), tm("r"), n, um(thinkNudgeStuck)},
|
||||
[]Message{um("q"), atc("glob"), tm("r")}},
|
||||
{"vraie demande en fin", []Message{um("q"), am("a"), um("suite")}, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
orig, _ := json.Marshal(c.in)
|
||||
got := dropStrayNudges(c.in)
|
||||
if after, _ := json.Marshal(c.in); string(after) != string(orig) {
|
||||
t.Errorf("%s : l'entrée a été modifiée", c.name)
|
||||
}
|
||||
want := c.want
|
||||
if want == nil {
|
||||
want = c.in
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s :\n got %+v\n want %+v", c.name, got, want)
|
||||
}
|
||||
assertAlternance(t, got)
|
||||
}
|
||||
}
|
||||
|
||||
// La réduction forcée coupe aux vraies demandes ; un rappel ne sert de coupe
|
||||
// qu'à défaut.
|
||||
func TestShrinkCoupeAuxVraiesDemandes(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := SetConfigKey("CTX", "4096"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
big := am(strings.Repeat("raisonnement long ", 400))
|
||||
sys := Message{Role: "system", Content: "SYS"}
|
||||
out, changed := shrinkToFit([]Message{sys, um("demande A"), big, um(thinkNudgeFirst), big, um("demande B"), am("ok")}, 0)
|
||||
if !changed || len(out) < 2 || msgText(out[1]) != "demande B" {
|
||||
t.Fatalf("coupe attendue à « demande B » : %+v", out)
|
||||
}
|
||||
out, changed = shrinkToFit([]Message{sys, um("demande A"), big, um(thinkNudgeFirst), big}, 0)
|
||||
if !changed || len(out) < 2 || msgText(out[1]) != thinkNudgeFirst {
|
||||
t.Fatalf("sans autre demande, la coupe doit retomber sur le rappel : %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Le rappel de budget part au moteur ET dans l'historique renvoyé : le tour
|
||||
// suivant rejoue les mêmes octets.
|
||||
func TestRappelDeBudgetPersisteTelQuEnvoye(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
if err := SetConfigKey("AGENT_BUDGET", "1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bodies := reqs()
|
||||
if len(bodies) != 2 {
|
||||
t.Fatalf("%d requêtes, attendu 2", len(bodies))
|
||||
}
|
||||
sent := reqMessages(t, bodies[1])
|
||||
last := sent[len(sent)-1]
|
||||
if !strings.HasPrefix(msgText(last), lokiNotePrefix) {
|
||||
t.Fatalf("rappel absent de la requête : %+v", last)
|
||||
}
|
||||
if len(extra) != 3 || !reflect.DeepEqual(extra[2], last) {
|
||||
t.Fatalf("historique renvoyé %+v : le rappel doit y être tel qu'envoyé", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// « Pensé sans agir » : persisté après un résultat d'outil, éphémère juste
|
||||
// après le message de l'utilisateur (deux user d'affilée).
|
||||
func TestRelancePenseSansAgirPersistee(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
reqs := scriptedServer(t, sseGlobCall, sseStop, sseChunk("fini")+sseStop)
|
||||
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(reqs()); n != 3 {
|
||||
t.Fatalf("%d requêtes, attendu 3", n)
|
||||
}
|
||||
if len(extra) != 3 || extra[2].Role != "user" || msgText(extra[2]) != thinkNudgeFirst {
|
||||
t.Fatalf("relance non persistée après l'outil : %+v", extra)
|
||||
}
|
||||
|
||||
reqs = scriptedServer(t, sseStop, sseChunk("fini")+sseStop)
|
||||
extra, err = runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bodies := reqs(); len(bodies) != 2 || msgText(reqMessages(t, bodies[1])[1]) != thinkNudgeFirst {
|
||||
t.Fatal("la relance doit quand même partir au moteur")
|
||||
}
|
||||
if len(extra) != 0 {
|
||||
t.Fatalf("relance persistée derrière la demande (deux user d'affilée) : %+v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// Relance après un 500 (llama-server local) : mêmes outils, même message 0,
|
||||
// tool_choice none et la consigne au bout du dernier message — qui n'entre
|
||||
// pas dans l'historique.
|
||||
func TestRelance500GardeLePrompt(t *testing.T) {
|
||||
testHome(t)
|
||||
reqs := scriptedServer(t, "500", sseChunk("voici")+sseStop)
|
||||
in := []Message{{Role: "system", Content: "SYS"}, um("question")}
|
||||
extra, err := runChat(t.Context(), in, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bodies := reqs()
|
||||
if len(bodies) != 2 {
|
||||
t.Fatalf("%d requêtes, attendu 2", len(bodies))
|
||||
}
|
||||
first, retry := bodies[0], bodies[1]
|
||||
if first["tools"] == nil || !reflect.DeepEqual(first["tools"], retry["tools"]) {
|
||||
t.Fatal("les outils doivent rester identiques dans la relance")
|
||||
}
|
||||
if _, ok := first["tool_choice"]; ok {
|
||||
t.Fatal("tool_choice posé hors relance")
|
||||
}
|
||||
if retry["tool_choice"] != "none" {
|
||||
t.Fatalf("tool_choice = %v, attendu none", retry["tool_choice"])
|
||||
}
|
||||
m0, r0 := reqMessages(t, first), reqMessages(t, retry)
|
||||
if !reflect.DeepEqual(m0[0], r0[0]) {
|
||||
t.Fatalf("message 0 modifié : %+v → %+v", m0[0], r0[0])
|
||||
}
|
||||
if got := msgText(r0[len(r0)-1]); got != "question\n\n"+lokiNotePrefix+toolsOffHint {
|
||||
t.Fatalf("consigne absente du bout du fil : %q", got)
|
||||
}
|
||||
if len(extra) != 0 {
|
||||
t.Fatalf("la consigne ne doit pas être persistée : %+v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// Repli : la relance « none » échoue à son tour (500, ou appel tenté malgré
|
||||
// tout) → chemin historique, outils retirés et consigne dans le système.
|
||||
func TestRelance500ReplieSurLeCheminHistorique(t *testing.T) {
|
||||
leak := `data: {"choices":[{"delta":{"content":"<tool_call>"}}]}` + "\n\n" + sseStop
|
||||
for name, second := range map[string]string{"second 500": "500", "refus 4xx": "400", "appel en texte": leak,
|
||||
"appel par le protocole": sseGlobCall, "réponse vide": sseStop} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
testHome(t)
|
||||
reqs := scriptedServer(t, "500", second, sseChunk("voici")+sseStop)
|
||||
in := []Message{{Role: "system", Content: "SYS"}, um("question")}
|
||||
ran := false
|
||||
if _, err := runChat(t.Context(), in, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
|
||||
ran = ran || ev.ToolUsed != nil
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("un appel émis sous tool_choice none a été exécuté")
|
||||
}
|
||||
bodies := reqs()
|
||||
if len(bodies) != 3 {
|
||||
t.Fatalf("%d requêtes, attendu 3", len(bodies))
|
||||
}
|
||||
last := bodies[2]
|
||||
if _, ok := last["tools"]; ok {
|
||||
t.Fatal("le repli doit retirer les outils")
|
||||
}
|
||||
if _, ok := last["tool_choice"]; ok {
|
||||
t.Fatal("tool_choice ne doit pas survivre au repli")
|
||||
}
|
||||
msgs := reqMessages(t, last)
|
||||
if !strings.Contains(msgText(msgs[0]), toolsOffHint) || strings.Contains(msgText(msgs[len(msgs)-1]), toolsOffHint) {
|
||||
t.Fatalf("consigne du repli mal placée : %+v", msgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Preset externe : la relance garde le chemin historique dès le premier 500.
|
||||
func TestRelance500ExterneInchangee(t *testing.T) {
|
||||
testHome(t)
|
||||
var mu sync.Mutex
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
bodies = append(bodies, body)
|
||||
n := len(bodies)
|
||||
mu.Unlock()
|
||||
if n == 1 {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(sseChunk("voici") + sseStop))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := runChat(t.Context(), []Message{um("question")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(bodies) != 2 {
|
||||
t.Fatalf("%d requêtes, attendu 2", len(bodies))
|
||||
}
|
||||
if _, ok := bodies[1]["tools"]; ok {
|
||||
t.Fatal("preset externe : la relance doit retirer les outils, comme avant")
|
||||
}
|
||||
if _, ok := bodies[1]["tool_choice"]; ok {
|
||||
t.Fatal("preset externe : pas de tool_choice")
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,31 @@ func oaiHandler() http.Handler {
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
// /slots en lecture seulement : save, restore et erase restent à Loki
|
||||
// (llm_slots.go).
|
||||
if slotsWrite(r) {
|
||||
sendOAIError(w, http.StatusMethodNotAllowed,
|
||||
"/slots est en lecture seule à travers Loki", "invalid_request_error", "method_not_allowed")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") {
|
||||
// Requête en vol vers le moteur : l'isolation des travaux annexes
|
||||
// n'efface pas le slot pendant ce temps. Différé : ReverseProxy panique
|
||||
// (ErrAbortHandler) quand le client coupe en plein flux.
|
||||
defer engineRequestStart()()
|
||||
// Complétion d'un client externe : elle prend le slot sous le nez de
|
||||
// la conversation. Notée pour nommer la perte de cache qui suit.
|
||||
if r.Method == http.MethodPost && strings.HasPrefix(p, "/v1/") {
|
||||
// Pendant un benchmark, elle fausserait la mesure et attendrait des
|
||||
// minutes derrière lui : refus explicite, à retenter.
|
||||
if benchRunning() {
|
||||
w.Header().Set("Retry-After", "30")
|
||||
sendOAIError(w, http.StatusServiceUnavailable,
|
||||
"benchmark en cours sur ce moteur — réessaie dans un moment", "server_error", "benchmark_running")
|
||||
return
|
||||
}
|
||||
perfNoteForeign()
|
||||
}
|
||||
lp.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Flux coupé vers une API EXTERNE après un début de réponse : le tour reprend,
|
||||
// le modèle reçoit le texte déjà affiché suivi d'un « continue », et la suite
|
||||
// s'ajoute à l'écran sans doublon ni erreur.
|
||||
func TestRepriseApresCoupureAPIExterne(t *testing.T) {
|
||||
testHome(t)
|
||||
var n int32
|
||||
var second []Message
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(sseChunk("Bonjour ")))
|
||||
w.(http.Flusher).Flush()
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
second = body.Messages
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(sseChunk("le monde")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
var gotErr error
|
||||
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
|
||||
if ev.Err != nil {
|
||||
gotErr = ev.Err
|
||||
}
|
||||
content.WriteString(ev.Content)
|
||||
return true
|
||||
})
|
||||
if err != nil || gotErr != nil {
|
||||
t.Fatalf("coupure externe non reprise : err=%v, ev=%v", err, gotErr)
|
||||
}
|
||||
if got := content.String(); got != "Bonjour le monde" {
|
||||
t.Fatalf("texte affiché = %q", got)
|
||||
}
|
||||
if len(second) < 3 || second[len(second)-2].Role != "assistant" || second[len(second)-2].Content != "Bonjour " ||
|
||||
second[len(second)-1].Role != "user" {
|
||||
t.Fatalf("la reprise n'a pas rendu le début de réponse au modèle : %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
// Deux appels d'outils parallèles livrés chacun en position 0 de son chunk : le
|
||||
// champ index les sépare. Avant, ils fusionnaient en un seul appel aux
|
||||
// arguments collés « {…}{…} ».
|
||||
func TestAppelsParallelesSuiventLIndex(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
var n int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
if atomic.AddInt32(&n, 1) == 1 {
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"b","type":"function","function":{"name":"grep","arguments":"{\"pattern\":\"rien\"}"}}]}}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(sseChunk("fini")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
}
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var done []string
|
||||
extra, err := runChat(context.Background(), []Message{{Role: "user", Content: "cherche"}}, 0.7, Caps{Agent: true, Code: true}, func(ev StreamEvent) bool {
|
||||
if ev.ToolUsed != nil && ev.ToolUsed.Done {
|
||||
done = append(done, ev.ToolUsed.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(done) != 2 || done[0] != "glob" || done[1] != "grep" {
|
||||
t.Fatalf("appels exécutés = %v, attendu [glob grep]", done)
|
||||
}
|
||||
if len(extra) == 0 || len(extra[0].ToolCalls) != 2 {
|
||||
t.Fatalf("message assistant : %+v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// Serveur sans `timings` (API tierce) : le débit de décodage est mesuré côté
|
||||
// Loki au lieu de rester à zéro.
|
||||
func TestDebitMesureSansTimings(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
for _, s := range []string{"un ", "deux ", "trois"} {
|
||||
_, _ = w.Write([]byte(sseChunk(s)))
|
||||
w.(http.Flusher).Flush()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}` + "\n\ndata: [DONE]\n\n"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var last *StatsEvent
|
||||
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "compte"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
|
||||
if ev.Stats != nil {
|
||||
last = ev.Stats
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if last == nil || last.GenTokens != 3 || last.GenPerSecond <= 0 {
|
||||
t.Fatalf("stats sans timings = %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// Un refus qui n'a rien d'un appel d'outil mal formé (401 d'une API externe)
|
||||
// remonte tel quel : avant, n'importe quel statut coupait les outils et
|
||||
// rejouait le tour, masquant la vraie cause derrière une réponse sans outils.
|
||||
func TestRefusAPIHors500NeCoupePasLesOutils(t *testing.T) {
|
||||
testHome(t)
|
||||
var n int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&n, 1)
|
||||
w.WriteHeader(401)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err == nil || !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("erreur = %v, attendu le 401 de l'API", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&n); got != 1 {
|
||||
t.Fatalf("%d requêtes : un 401 a été rejoué sans outils", got)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ const (
|
||||
llmNetMaxWait = 5 * time.Second
|
||||
)
|
||||
|
||||
// wsaECONNREFUSED : la connexion refusée telle que Windows la rend (WSA 10061).
|
||||
// syscall.ECONNREFUSED n'y est qu'une valeur inventée par Go, jamais renvoyée
|
||||
// par le système ; ailleurs, 10061 n'est le numéro d'aucune erreur.
|
||||
const wsaECONNREFUSED = syscall.Errno(10061)
|
||||
|
||||
// llmRetryableErr : cette erreur de transport vaut-elle une nouvelle tentative ?
|
||||
//
|
||||
// Un ctx déjà fini n'en vaut JAMAIS une : c'est le /stop de l'utilisateur ou la
|
||||
@@ -57,7 +62,7 @@ func llmRetryableErr(ctx context.Context, err error) bool {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, syscall.ECONNREFUSED): // moteur en cours de démarrage
|
||||
case errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, wsaECONNREFUSED): // moteur en cours de démarrage
|
||||
return true
|
||||
case errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return true
|
||||
@@ -122,3 +127,9 @@ func llmNetBackoff(ctx context.Context, n int) error {
|
||||
func logLLMRetry(attempt int, cause string) {
|
||||
fmt.Fprintf(os.Stderr, "[llm] %s — nouvelle tentative %d/%d\n", cause, attempt, llmNetRetries)
|
||||
}
|
||||
|
||||
// logStreamRetry trace la reprise d'un flux coupé EN COURS de réponse (API
|
||||
// externe) : budget distinct de celui d'avant le flux, voir runChat.
|
||||
func logStreamRetry(attempt, max int, cause error) {
|
||||
fmt.Fprintf(os.Stderr, "[llm] flux coupé en cours de réponse (%v) — reprise %d/%d\n", cause, attempt, max)
|
||||
}
|
||||
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user