Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 ac74ecde0b Façade : see_video exécuté par la façade, vidéos jointes rangées relues
Comme see_image (lot 45), see_video est tenu par la façade :
imagetools::see_video porte toolSeeVideo (mêmes refus, même ordre,
sonde du moteur en dernier) et video_input_supported la capacité du
moteur (aide du binaire, --video-fps, relue quand il change). La vidéo
relayée est toujours éphémère, KEEP_TURN_IMAGES compris (turn.rs) ;
vision_video relu de /tool/exec quand le Go exécute l'outil.

Vidéos jointes rangées (loki-vid:) : remises en data-URL avant l'envoi
avec les images (expand_image_refs, cache des médias élargi à chatvid).
Libellé et non-dédup de see_video, videoLostMarker à la compaction et
au rechargement d'un fil, élision dans l'export.

Parité : journal tiré du Go (parity_video_tool_test.go →
tests/parity_images/video.json : refus, moteur sans vidéo, moteur
aveugle, vidéos relayées à l'octet), rejoué par tests/videotools.rs ;
journal multiplateforme (ni faux Playwright ni sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:17 +02:00
MichaelandClaude Opus 5.5 776de75dd0 Vision : le modèle peut regarder des vidéos (outil see_video et vidéos jointes), repris d'AJEAN
Reprise des commits 91f7f977 (PR #100 de Trombo38) et 1dfac0cf d'AJEAN.
Une vidéo jointe (mp4, webm, mov, mkv, avi) part au modèle en partie
input_video, et l'outil see_video charge une vidéo du disque, mais
seulement si le moteur actif sait la lire : preset llama.cpp local avec
projecteur (MMPROJ) et binaire dont l'aide annonce --video-fps
(videoInputSupported, aide relue quand le binaire change, chemin des
bibliothèques passé à la seule commande pour ne pas figer binHelp).
Strata et les API externes ignoraient la partie en silence : la vidéo
reste alors un fichier, annoncé avec la marche à suivre (ffmpeg puis
see_image), et see_video n'est pas proposé.

llama.cpp a --video-fps depuis la PR #24318 (fin août 2026) ; l'image
server-cuda officielle, base du conteneur, installe ffmpeg. La consigne
de repli dit quoi faire si ffmpeg manque (installation hors conteneur).

Adapté à Loki :
- la vidéo de see_video est toujours éphémère (jamais dans l'historique,
  KEEP_TURN_IMAGES compris) ; même ordre de refus que see_image, sonde
  /props en dernier, refus sur un poste distant ;
- une vidéo jointe est rangée par référence (loki-vid:, LOKI_HOME/chatvid,
  chiffrée comme le fil), parties recopiées et non mutées comme les
  images ; retirée d'un fil rouvert (stripImageParts, videoLostMarker) et
  effacée par le ménage des médias ;
- marqueur de perte à la compaction, élision dans l'export JSON, relais
  par l'API interne des outils (vision_video).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:17 +02:00
LogiFlow 6a45f19831 Merge pull request #34 from R0m1k3/fix/lot1-ajean-openfox
Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
2026-10-19 11:22:20 +02:00
MichaelandClaude Opus 5.5 4c2cf8b4e6 Façade : délai d'appel MCP par serveur et par argument, pas de relance au délai dépassé
Même règle que le Go (mcpCallTimeoutFor) : 120 s par défaut, remplacés par
callTimeoutSec du serveur, allongés par l'argument timeout de l'outil (+30 s,
millisecondes au-delà de 3600), plafond 30 min. Un délai dépassé rend la même
erreur que le Go sans reconnecter ni relancer l'outil. callTimeoutSec est lu
et réécrit tel quel dans la configuration des serveurs : un enregistrement
par la façade ne le perd plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 68dd65cd5a Façade : motifs de relance vides écartés, comme le Go
Un motif vide (ou blanc) dans retry_patterns donnait (?mi), qui correspond à
tout : chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
motif utilisable, la façade retombe sur les motifs embarqués, comme
compileRetryPatterns côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 01c11be4ae Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
- LSP (lsp.go) : un signal resté dans le tampon par une republication
  tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même
  que le serveur ait vu la nouvelle version. Le canal est vidé, une
  publication d'une version antérieure est ignorée, et check attend 350 ms de
  calme après la dernière publication (liste vide puis analyse : c'est
  l'analyse qui compte), toujours borné par lspDiagWait. Au passage :
  interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par
  check) — dead devient atomique.
- run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan
  sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui
  l'effaçait (AJEAN 7403a8a0).
- Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir
  la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par
  toute écriture du bucket d'état, par la date/taille du fichier (autre
  process) et au bout d'une seconde.
- MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par
  serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil,
  plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8).
- Recall : les blocs d'une compaction s'archivent en une seule transaction
  au lieu d'une par bloc (AJEAN #130).
- Compactage en cours de tour : après un essai qui n'a rien réduit, pas de
  nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et
  sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN
  68659e73).
- Motifs de relance : un motif vide (qui correspondait à tout) est écarté,
  une surcharge sans motif utilisable retombe sur l'embarqué, et un
  changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869,
  c52c5bf7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
45 changed files with 3074 additions and 192 deletions

No files matched your search

+9
View File
@@ -52,6 +52,15 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
La **compréhension des vidéos** est reprise d'AJEAN (PR #100 de Trombo38 et
son correctif « vidéo envoyée seulement si le moteur sait la lire ») :
l'outil `see_video` et les vidéos jointes (`chat_video.go`,
`chat_video_tool.go`), lues par llama.cpp avec `--video-fps` et ffmpeg ;
ailleurs, la vidéo reste un fichier, avec la marche à suivre (ffmpeg puis
`see_image`). Adaptée à Loki : vidéo de `see_video` toujours éphémère, vidéos
jointes chiffrées comme le fil et retirées d'un fil rouvert, outil tenu aussi
par la façade Rust (`imagetools.rs`).
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
+62 -7
View File
@@ -130,7 +130,8 @@ const imageLostMarker = " [image — not kept in the conversation past this poin
// (userMessageContent : parties texte + image quand la vision est active) porte
// un tableau de parties ; on en recolle les segments `text` pour que l'estimation
// de contexte et le transcript de compaction ne partent pas d'un texte vide, et
// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans
// on marque la présence d'une partie `image_url` (voir imageLostMarker) ou
// `input_video` (videoLostMarker, chat_video.go) — sans
// quoi elle reste invisible à toute la chaîne de compaction.
func msgText(m Message) string {
switch v := m.Content.(type) {
@@ -146,6 +147,8 @@ func msgText(m Message) string {
}
case "image_url":
b.WriteString(imageLostMarker)
case "input_video":
b.WriteString(videoLostMarker)
}
}
return b.String()
@@ -163,6 +166,8 @@ func msgText(m Message) string {
}
case "image_url":
b.WriteString(imageLostMarker)
case "input_video":
b.WriteString(videoLostMarker)
}
}
return b.String()
@@ -242,6 +247,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
}
// compactRetryDue : une compaction en cours de tour peut-elle être tentée ?
// Après un essai qui n'a rien réduit (résumé raté, réduction sous 20 %),
// chaque étape suivante relançait un résumé complet voué au même échec : plus
// d'une heure de tentatives à vide, une par étape, sur une longue tâche (AJEAN
// 68659e73). compactRefusedSkip ne couvre que la discussion principale avec
// COMPACT_CONTINUATION ; ce garde-fou vaut pour tout tour, tâches et
// sous-agents compris. Jamais à 90 % de la fenêtre ou plus : là, essayer
// encore vaut mieux que déborder.
func compactRetryDue(retryAt, used int, msgs []Message) bool {
if retryAt <= 0 {
return true
}
est := compactUsedOrEstimate(used, msgs)
return est >= retryAt || est*10 >= ctxWindow()*9
}
// compactNextRetry : seuil du prochain essai après une compaction en cours de
// tour. 0 (aucun) si elle a réduit ; sinon le contexte d'aujourd'hui plus 5 %
// de la fenêtre.
func compactNextRetry(changed bool, used int, msgs []Message) int {
if changed {
return 0
}
est := compactUsedOrEstimate(used, msgs)
next := est + ctxWindow()/20
fmt.Fprintf(os.Stderr, "[compact] rien réduit en cours de tour : prochain essai à %d jetons (contexte à %d)\n", next, est)
return next
}
func compactUsedOrEstimate(used int, msgs []Message) int {
if used > 0 {
return used
}
return estimateTokens(msgs)
}
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
@@ -437,18 +478,24 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
var index []recallEntry
if caps.Agent && compactEnabled() {
names := torsoToolNames(torso)
var blocks []recallArchiveInput
var at []int // position dans le torse de chaque bloc
for i, m := range torso {
if !recallEligible(m) {
continue
}
content := msgText(m)
label := recallLabel(m.Role, names[m.ToolCallID], content)
id, aerr := archiveRecallBlock(label, m.Role, content)
if aerr != nil {
continue
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
at = append(at, i)
}
// Une seule transaction pour tous les blocs (AJEAN #130).
if entries, aerr := archiveRecallBlocks(blocks); aerr == nil {
for k, e := range entries {
archived[at[k]] = &recallEntry{id: e.id, label: e.label}
index = append(index, e)
}
archived[i] = &recallEntry{id: id, label: label}
index = append(index, recallEntry{id: id, label: label})
} else if len(blocks) > 0 {
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
}
}
@@ -528,6 +575,14 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
} else {
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
}
// La cause d'un résumé raté n'était écrite nulle part : on ne voyait que des
// compactions « changé=false » à répétition, sans savoir pourquoi (AJEAN
// 68659e73).
if err != nil {
fmt.Fprintf(os.Stderr, "[compact] résumé raté : %v\n", err)
} else if summaryLooksEmpty(summary) {
fmt.Fprintf(os.Stderr, "[compact] résumé vide ou dégénéré (%d car.)\n", len([]rune(strings.TrimSpace(summary))))
}
var mid []Message
if err != nil || summaryLooksEmpty(summary) {
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
+32
View File
@@ -0,0 +1,32 @@
package loki
import "testing"
// Après une compaction en cours de tour qui n'a rien réduit, l'étape suivante
// ne relance pas un résumé tant que le contexte n'a pas grossi de 5 % de la
// fenêtre — sauf à 90 % de la fenêtre ou plus.
func TestCompactRetryBackoff(t *testing.T) {
testHome(t)
if err := putStr(bkConfig, "CTX", "100000"); err != nil {
t.Fatal(err)
}
if !compactRetryDue(0, 70000, nil) {
t.Fatal("sans échec précédent, l'essai doit être permis")
}
next := compactNextRetry(false, 70000, nil)
if next != 75000 {
t.Fatalf("prochain essai à %d, attendu 75000", next)
}
if compactRetryDue(next, 72000, nil) {
t.Fatal("relancé alors que le contexte n'a presque pas grossi")
}
if !compactRetryDue(next, 75000, nil) {
t.Fatal("pas relancé alors que le contexte a grossi de 5 % de la fenêtre")
}
if !compactRetryDue(200000, 90000, nil) {
t.Fatal("à 90 % de la fenêtre, l'essai doit toujours être permis")
}
if compactNextRetry(true, 70000, nil) != 0 {
t.Fatal("une compaction réussie doit lever le garde-fou")
}
}
+16 -1
View File
@@ -112,7 +112,8 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
}
// stripInlineImages remplace les images en base64 (data URI) portées par les
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
// messages multimodaux (vision : pièces jointes images et vidéos + outil
// see_image) par un
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
@@ -145,6 +146,20 @@ func stripInlineImagesValue(v any) any {
}
cp["image_url"] = c2
}
// Partie vidéo (input_video, chat_video.go) : même élision, sur les deux
// champs que lit llama-server.
if iv, ok := cp["input_video"].(map[string]any); ok {
c2 := make(map[string]any, len(iv))
for k, val := range iv {
c2[k] = val
}
for _, key := range []string{"url", "data"} {
if s, ok := c2[key].(string); ok && strings.HasPrefix(s, "data:") {
c2[key] = elideDataURI(s)
}
}
cp["input_video"] = c2
}
return cp
}
switch parts := v.(type) {
+5 -1
View File
@@ -123,7 +123,11 @@ const chatImgKeep = 24 * time.Hour
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
// fil resté ouvert longtemps sans bascule.
func pruneChatImages() {
dir := chatImgDir()
pruneMediaDir(chatImgDir())
pruneMediaDir(chatVidDir()) // vidéos jointes (chat_video.go), même délai
}
func pruneMediaDir(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
+1
View File
@@ -536,6 +536,7 @@ func (c *Conversation) snapshot() *convSnap {
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
refImagesInMessages(c.Messages)
refVideosInMessages(c.Messages) // vidéos jointes, même rangement (chat_video.go)
// En plein tour, le journal porte encore un événement par token et chaque
// frappe d'outil : on écrit la forme que produira la fin de tour
// (compactLog), sans toucher au journal en mémoire que suivent les abonnés.
+65 -35
View File
@@ -78,58 +78,88 @@ func recallIDToSeq(id string) (uint64, bool) {
return n, true
}
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
// l'appelant se rabat sur le compactage classique (troncature sans id).
func archiveRecallBlock(label, role, content string) (string, error) {
// recallArchiveInput : un bloc à archiver (voir archiveRecallBlocks).
type recallArchiveInput struct {
Label string
Role string
Content string
}
// archiveRecallBlocks enregistre tous les blocs d'une compaction dans UNE
// transaction et renvoie leurs entrées, dans l'ordre des blocs fournis. Les ids
// sont alloués par NextSequence dans la transaction : monotones, sans
// collision, même si deux compactages se croisent. Une transaction par bloc
// payait une ouverture de la base et une synchronisation disque chacune, sous
// le verrou global de la base, en plein tour (AJEAN #130). Tout ou rien : en
// cas d'échec (base indisponible, ex. tests sans LOKI_HOME, mémoire
// verrouillée) aucun id n'est rendu, et l'appelant se rabat sur le compactage
// classique (troncature sans id).
func archiveRecallBlocks(blocks []recallArchiveInput) ([]recallEntry, error) {
if len(blocks) == 0 {
return nil, nil
}
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
encode, err := memEncoderNow()
if err != nil {
return "", err
return nil, err
}
var id string
now := time.Now().Unix()
var entries []recallEntry
err = withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
entries = entries[:0]
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
if err != nil {
return err
}
seq, err := b.NextSequence()
if err != nil {
return err
for _, in := range blocks {
seq, err := b.NextSequence()
if err != nil {
return err
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: in.Label,
Role: in.Role,
Content: in.Content,
Created: now,
}
raw, err := json.Marshal(blk)
if err != nil {
return err
}
// Un bloc archivé, c'est du verbatim de conversation : chiffré
// comme le reste quand la mémoire l'est (l'encodeur rend le clair
// inchangé si le chiffrement est inactif ; verrouillé,
// memEncoderNow a déjà refusé plus haut).
enc, err := encode(raw)
if err != nil {
return err
}
if err := b.Put(recallKey(seq), enc); err != nil {
return err
}
entries = append(entries, recallEntry{id: blk.ID, label: blk.Label})
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: label,
Role: role,
Content: content,
Created: time.Now().Unix(),
}
id = blk.ID
raw, err := json.Marshal(blk)
if err != nil {
return err
}
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
// refusé plus haut).
enc, err := encode(raw)
if err != nil {
return err
}
return b.Put(recallKey(seq), enc)
return nil
})
})
if err != nil {
return "", err
return nil, err
}
cacheBust(bkRecall)
return id, nil
return entries, nil
}
// archiveRecallBlock enregistre un seul bloc et renvoie son id ("r<seq>").
func archiveRecallBlock(label, role, content string) (string, error) {
e, err := archiveRecallBlocks([]recallArchiveInput{{Label: label, Role: role, Content: content}})
if err != nil {
return "", err
}
return e[0].id, nil
}
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
+44
View File
@@ -221,3 +221,47 @@ func TestCompactSummaryUserMsg(t *testing.T) {
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
}
}
// Les blocs d'une compaction s'écrivent en une seule transaction : ids dans
// l'ordre des blocs, croissants, chacun relisible mot pour mot.
func TestArchiveRecallBlocksBatch(t *testing.T) {
testHome(t)
if e, err := archiveRecallBlocks(nil); err != nil || e != nil {
t.Fatalf("lot vide : %v, %v", e, err)
}
in := []recallArchiveInput{
{Label: "un", Role: "tool", Content: bigBlock("PREMIER")},
{Label: "deux", Role: "user", Content: bigBlock("SECOND")},
{Label: "trois", Role: "assistant", Content: bigBlock("TROISIEME")},
}
entries, err := archiveRecallBlocks(in)
if err != nil {
t.Fatal(err)
}
if len(entries) != len(in) {
t.Fatalf("%d entrées pour %d blocs", len(entries), len(in))
}
var last uint64
for i, e := range entries {
seq, ok := recallIDToSeq(e.id)
if !ok || seq <= last {
t.Fatalf("id %q non croissant (précédent %d)", e.id, last)
}
last = seq
if e.label != in[i].Label {
t.Fatalf("entrée %d : libellé %q, attendu %q", i, e.label, in[i].Label)
}
blk, ok := recallGet(e.id)
if !ok || blk.Content != in[i].Content || blk.Role != in[i].Role {
t.Fatalf("bloc %s mal relu : %+v", e.id, blk)
}
}
// Un archivage isolé suit la même séquence.
id, err := archiveRecallBlock("quatre", "tool", bigBlock("QUATRE"))
if err != nil {
t.Fatal(err)
}
if seq, _ := recallIDToSeq(id); seq != last+1 {
t.Fatalf("id isolé %q, attendu r%d", id, last+1)
}
}
+17 -5
View File
@@ -163,7 +163,8 @@ func engineSeesImages() bool {
// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le
// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire
// au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne.
// Vaut pour toutes les provenances : capture, pièce jointe, see_image.
// Vaut pour toutes les provenances : capture, pièce jointe, see_image — et pour
// les vidéos jointes (input_video), marquées par videoLostMarker.
func stripImageParts(msgs []Message) []Message {
for i, m := range msgs {
parts, ok := m.Content.([]any)
@@ -171,22 +172,33 @@ func stripImageParts(msgs []Message) []Message {
continue
}
var texts []string
dropped := false
dropped, droppedVid := false, false
for _, p := range parts {
pm, ok := p.(map[string]any)
if !ok {
continue
}
if pm["type"] == "image_url" {
switch pm["type"] {
case "image_url":
dropped = true
continue
case "input_video": // vidéo jointe (chat_video.go) : même sort
droppedVid = true
continue
}
if t, ok := pm["text"].(string); ok {
texts = append(texts, t)
}
}
if dropped {
msgs[i].Content = strings.Join(texts, "\n") + imageLostMarker
if dropped || droppedVid {
text := strings.Join(texts, "\n")
if dropped {
text += imageLostMarker
}
if droppedVid {
text += videoLostMarker
}
msgs[i].Content = text
}
}
return msgs
+7 -3
View File
@@ -41,11 +41,15 @@ func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
}
testHome(t)
start := time.Now()
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
if d := time.Since(start); d > 10*time.Second {
// Délai de 10 s, bien au-delà de WaitDelay (2 s) : c'est le détachement
// qui rend la main, pas le délai de la commande.
out := runShell(context.Background(), "sleep 30 & echo lance", 10)
if d := time.Since(start); d > 8*time.Second {
t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
}
if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
// La commande a fini (exit 0) : sa sortie est rendue, pas effacée par
// « WaitDelay expired » (AJEAN 7403a8a0).
if !strings.Contains(out, "exit: 0") || !strings.Contains(out, "lance") || !strings.Contains(out, "arrière-plan") {
t.Errorf("sortie inattendue : %q", out)
}
}
+11
View File
@@ -297,6 +297,14 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
// issue connue était de redémarrer loki-ui.
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
// ErrWaitDelay : la commande s'est terminée normalement, mais un process
// laissé en arrière-plan tenait encore les tubes. Ce qu'elle a écrit est
// déjà capturé : le rendre, au lieu de « [erreur: exec: WaitDelay expired
// before I/O complete] » qui l'effaçait (repris d'AJEAN 7403a8a0).
detached := errors.Is(err, exec.ErrWaitDelay)
if detached {
err = nil
}
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
// demandait un tail.
clean := func(s string) string { return tailOutput(stripANSI(s)) }
@@ -337,6 +345,9 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
if errOut != "" {
parts = append(parts, "stderr:\n"+errOut)
}
if detached {
parts = append(parts, "(un process lancé en arrière-plan tourne toujours ; sa sortie n'est plus suivie — pour un serveur, utilise bash_bg)")
}
return strings.Join(parts, "\n\n")
}
+333
View File
@@ -0,0 +1,333 @@
package loki
// chat_video.go — vidéos vues par le modèle (repris d'AJEAN, PR #100 et son
// correctif « vidéo envoyée seulement si le moteur sait la lire »).
//
// Trois morceaux :
// - videoInputSupported : le moteur actif sait-il lire une partie
// `input_video` ? Seul un llama.cpp local récent le fait (option
// --video-fps, décodage par ffmpeg). Une API externe ou Strata ignorent la
// partie SANS erreur : le modèle ne recevait que le texte et cherchait à voir
// la vidéo avec see_image. Ailleurs, la vidéo reste un simple fichier, avec
// la marche à suivre (videoFallbackNote) ;
// - une vidéo JOINTE au message est rangée PAR RÉFÉRENCE, comme les images
// (chat_images.go) : une fois sur disque sous LOKI_HOME/chatvid/, chiffrée
// comme le fil, et l'historique n'en garde que « loki-vid:<nom> ».
// expandVideoRefs remet les octets juste avant l'envoi au moteur ;
// - la vidéo chargée par l'outil see_video (chat_video_tool.go) est, elle,
// TOUJOURS éphémère : elle part dans le tour en cours et jamais dans
// l'historique, KEEP_TURN_IMAGES compris (des milliers de jetons visuels).
//
// Écart avec l'amont : comme les images, un fil rechargé perd ses vidéos
// (stripImageParts, avec videoLostMarker) et pruneChatImages vide aussi
// chatvid/ — Loki ne rejoue jamais un média d'un fil rouvert.
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
const vidRefScheme = "loki-vid:"
func chatVidDir() string { return filepath.Join(LokiHome(), "chatvid") }
// maxVideoBytes borne la taille d'une vidéo montrée au modèle : le moteur en
// tire des images à --video-fps (4 par seconde par défaut), donc une vidéo
// longue = des dizaines de milliers de jetons visuels. Au-delà, le contexte
// sature sans que le modèle puisse raisonner dessus.
const maxVideoBytes = 200 << 20 // 200 Mio
// videoMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme vidéo (partie
// input_video). Le moteur décode lui-même avec ffmpeg ; un format hors de cette
// liste reste un simple fichier du dossier de travail.
var videoMimes = map[string]string{
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
}
// videoMime renvoie le type MIME vidéo d'un nom de fichier, ou "" si ce n'est
// pas une vidéo qu'on sait montrer au modèle.
func videoMime(name string) string {
return videoMimes[strings.ToLower(filepath.Ext(name))]
}
// videoFallbackNote : consigne quand le moteur ne lit pas la vidéo. Le fichier
// reste utilisable : quelques images extraites avec ffmpeg, puis see_image.
// ffmpeg est dans l'image Docker de Loki (l'image serveur officielle de
// llama.cpp l'installe pour son décodage vidéo) ; une installation hors
// conteneur peut ne pas l'avoir, d'où la dernière phrase.
const videoFallbackNote = "Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.\n"
// videoLostMarker : équivalent d'imageLostMarker pour une partie input_video
// (résumé de compaction, fil rechargé).
const videoLostMarker = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]"
// videoPart construit la partie multimodale {type:input_video, input_video:{url}}
// — le format que llama-server lit une fois --mmproj chargé.
func videoPart(b []byte, mime string) map[string]any {
return map[string]any{
"type": "input_video",
"input_video": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
},
}
}
// videoInputSupported : le moteur actif sait-il lire une partie input_video ?
// Preset local avec projecteur (MMPROJ) ET binaire dont l'aide annonce
// --video-fps. Variable : remplaçable dans les tests.
var videoInputSupported = func() bool {
cfg := ReadConfig()
if isExternalConfig(cfg) || isStrataConfig(cfg) || strings.TrimSpace(cfg["MMPROJ"]) == "" {
return false
}
bin := strings.TrimSpace(cfg["BIN"])
if bin == "" {
return false
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(LokiHome(), bin)
}
return binHelpHasVideo(prebuiltResolveBin(bin))
}
var videoCapCache sync.Map // "binaire\x00mtime" → bool
// binHelpHasVideo lit l'aide du binaire, une fois par binaire ET par date de
// modification (un moteur mis à jour en place est relu). Volontairement à part
// de binHelp : celle-ci garde aussi une aide VIDE pour toujours, et l'appeler
// avant le lancement du moteur (chemin des bibliothèques pas encore posé)
// figerait « aucune capacité » pour le vrai lancement. Ici, le chemin des
// bibliothèques est passé à la seule commande (libraryPathEnv).
func binHelpHasVideo(bin string) bool {
st, err := os.Stat(bin)
if err != nil || st.IsDir() {
return false
}
key := bin + "\x00" + st.ModTime().String()
if v, ok := videoCapCache.Load(key); ok {
return v.(bool)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "--help")
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput()
ok := strings.Contains(string(out), "--video-fps")
videoCapCache.Store(key, ok)
return ok
}
// --- Vidéos jointes, par référence -------------------------------------------
var vidExtByMime = map[string]string{
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/quicktime": ".mov",
"video/x-matroska": ".mkv",
"video/x-msvideo": ".avi",
}
// storeChatVideo range les octets d'une vidéo et renvoie sa référence. Chiffrée
// comme le fil quand la mémoire l'est ; verrouillée : refus, la vidéo reste en
// base64 dans le message (rien ne se perd).
func storeChatVideo(b []byte, mime string) (string, error) {
ext := vidExtByMime[mime]
if ext == "" {
ext = ".vid"
}
sum := sha256.Sum256(b)
name := hex.EncodeToString(sum[:16]) + ext
dir := chatVidDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err != nil {
enc, err := encodeMemContent(b)
if err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, p); err != nil {
return "", err
}
}
return vidRefScheme + name, nil
}
// Cache des data-URL reconstituées (même raison que imgCache : un tour d'agent
// renvoie tout l'historique à chaque étape). Borné en taille.
var (
vidCacheMu sync.Mutex
vidCache = map[string]string{}
vidCacheOrder []string
vidCacheBytes int
)
const vidCacheMax = 64 << 20
func chatVideoDataURL(ref string) (string, bool) {
name := strings.TrimPrefix(ref, vidRefScheme)
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return "", false
}
vidCacheMu.Lock()
if u, ok := vidCache[name]; ok {
vidCacheMu.Unlock()
return u, true
}
vidCacheMu.Unlock()
raw, err := os.ReadFile(filepath.Join(chatVidDir(), name))
if err != nil {
return "", false
}
b, err := decodeMemContent(raw)
if err != nil {
return "", false
}
mime := "video/mp4"
for m, e := range vidExtByMime {
if strings.HasSuffix(name, e) {
mime = m
}
}
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
vidCacheMu.Lock()
if _, ok := vidCache[name]; !ok {
vidCache[name] = u
vidCacheOrder = append(vidCacheOrder, name)
vidCacheBytes += len(u)
for vidCacheBytes > vidCacheMax && len(vidCacheOrder) > 1 {
old := vidCacheOrder[0]
vidCacheOrder = vidCacheOrder[1:]
vidCacheBytes -= len(vidCache[old])
delete(vidCache, old)
}
}
vidCacheMu.Unlock()
return u, true
}
// partVideoURL : l'adresse d'une partie input_video (url, sinon data — les deux
// formes que lit llama-server), "" pour toute autre partie.
func partVideoURL(p map[string]any) string {
if p["type"] != "input_video" {
return ""
}
iv, _ := p["input_video"].(map[string]any)
if iv == nil {
return ""
}
u, _ := iv["url"].(string)
if u == "" {
u, _ = iv["data"].(string)
}
return u
}
// refVideosInMessages remplace les vidéos en base64 des messages par des
// références. Comme refImagesInMessages, les parties modifiées sont RECOPIÉES,
// jamais mutées (persist peut tourner pendant qu'un runChat sérialise ces mêmes
// maps). Renvoie true si quelque chose a changé.
func refVideosInMessages(msgs []Message) bool {
changed := false
for mi, m := range msgs {
parts := contentParts(m.Content)
var np []map[string]any
for pi, p := range parts {
u := partVideoURL(p)
if !strings.HasPrefix(u, "data:video/") {
continue
}
i := strings.Index(u, ";base64,")
if i < 0 {
continue
}
b, err := base64.StdEncoding.DecodeString(u[i+8:])
if err != nil {
continue
}
ref, err := storeChatVideo(b, u[5:i])
if err != nil {
continue
}
if np == nil {
np = append([]map[string]any(nil), parts...)
}
np[pi] = map[string]any{"type": "input_video", "input_video": map[string]any{"url": ref}}
}
if np != nil {
msgs[mi].Content = np
changed = true
}
}
return changed
}
// hasInlineVideos : au moins une vidéo encore en base64 dans ces messages ?
func hasInlineVideos(msgs []Message) bool {
for _, m := range msgs {
for _, p := range contentParts(m.Content) {
if strings.HasPrefix(partVideoURL(p), "data:video/") {
return true
}
}
}
return false
}
// expandVideoRefs renvoie une COPIE des messages prête pour le modèle : chaque
// référence de vidéo redevient sa data-URL. Les messages sans référence sont
// partagés tels quels. Une vidéo introuvable devient une mention textuelle.
func expandVideoRefs(msgs []Message) []Message {
out := msgs
copied := false
for i, m := range msgs {
parts := contentParts(m.Content)
has := false
for _, p := range parts {
if strings.HasPrefix(partVideoURL(p), vidRefScheme) {
has = true
break
}
}
if !has {
continue
}
if !copied {
out = append([]Message(nil), msgs...)
copied = true
}
np := make([]map[string]any, 0, len(parts))
for _, p := range parts {
u := partVideoURL(p)
if !strings.HasPrefix(u, vidRefScheme) {
np = append(np, p)
continue
}
if d, ok := chatVideoDataURL(u); ok {
np = append(np, map[string]any{"type": "input_video", "input_video": map[string]any{"url": d}})
} else {
np = append(np, map[string]any{"type": "text", "text": "[vidéo indisponible : fichier supprimé]"})
}
}
m.Content = np
out[i] = m
}
return out
}
+332
View File
@@ -0,0 +1,332 @@
package loki
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// videoEngine simule un moteur qui sait (ou non) lire la vidéo.
func videoEngine(t *testing.T, ok bool) {
t.Helper()
prev := videoInputSupported
videoInputSupported = func() bool { return ok }
t.Cleanup(func() { videoInputSupported = prev })
}
// engineSees amorce le cache de la sonde /props : aucun llama-server ne tourne
// pendant les tests.
func engineSees(t *testing.T, seen bool) {
t.Helper()
visionProbeMu.Lock()
visionProbeAt, visionProbeSeen = time.Now(), seen
visionProbeMu.Unlock()
t.Cleanup(func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() })
}
// Une vidéo en base64 part sur disque et revient identique à l'envoi au modèle ;
// l'historique ne garde qu'une référence de quelques octets.
func TestVideoRefsRoundTrip(t *testing.T) {
testHome(t)
raw := []byte(strings.Repeat("\x00\x00\x00\x18ftypmp42-fake-video-bytes", 5000))
url := "data:video/mp4;base64," + base64.StdEncoding.EncodeToString(raw)
msgs := []Message{
{Role: "user", Content: []map[string]any{
{"type": "text", "text": "regarde la vidéo"},
{"type": "input_video", "input_video": map[string]any{"url": url}},
}},
{Role: "assistant", Content: "vu"},
}
// Relu depuis le JSON (forme []any), comme après un redémarrage.
var reread []Message
b, _ := json.Marshal(msgs)
_ = json.Unmarshal(b, &reread)
for _, set := range [][]Message{msgs, reread} {
orig := contentParts(set[0].Content)[1]
if !hasInlineVideos(set) || !refVideosInMessages(set) || hasInlineVideos(set) {
t.Fatal("la vidéo aurait dû être rangée par référence")
}
// Recopiée, jamais mutée : un runChat en vol peut lire ces mêmes maps.
if partVideoURL(orig) != url {
t.Fatal("refVideosInMessages a muté la partie d'origine")
}
js, _ := json.Marshal(set)
if len(js) > 500 || !strings.Contains(string(js), vidRefScheme) {
t.Fatalf("historique encore lourd : %d octets", len(js))
}
out := expandVideoRefs(set)
if got := partVideoURL(contentParts(out[0].Content)[1]); got != url {
t.Fatal("la vidéo renvoyée au modèle diffère de l'originale")
}
// L'historique lui-même n'est pas modifié par l'expansion.
if u := partVideoURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, vidRefScheme) {
t.Fatal("expandVideoRefs ne doit pas toucher l'historique")
}
}
// Fichier effacé (ménage) : une mention, pas une requête qui échoue.
_ = os.RemoveAll(chatVidDir())
vidCacheMu.Lock()
vidCache, vidCacheOrder, vidCacheBytes = map[string]string{}, nil, 0
vidCacheMu.Unlock()
out := expandVideoRefs(msgs)
if p := contentParts(out[0].Content)[1]; p["type"] != "text" || !strings.Contains(p["text"].(string), "indisponible") {
t.Fatalf("vidéo disparue : %v", p)
}
}
// userMessageContent : une vidéo jointe devient une partie input_video (et une
// image jointe reste une partie image_url), quand la vision est active et que
// le moteur lit la vidéo.
func TestUserMessageContentVideo(t *testing.T) {
videoEngine(t, true)
visionOn(t)
engineSees(t, true)
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "clip-test.mp4"), []byte("fake-mp4-bytes"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "photo.png"), pngPixel, 0o600); err != nil {
t.Fatal(err)
}
in := []attachInfo{
{Name: "clip-test.mp4", Path: "uploads/clip-test.mp4", Size: 14},
{Name: "photo.png", Path: "uploads/photo.png", Size: int64(len(pngPixel))},
}
parts, ok := userMessageContent(in, "décris ce clip").([]map[string]any)
if !ok || len(parts) != 3 {
t.Fatalf("attendu texte + image + vidéo, obtenu %#v", parts)
}
if parts[0]["type"] != "text" || parts[1]["type"] != "image_url" || parts[2]["type"] != "input_video" {
t.Fatalf("ordre des parties : %v %v %v", parts[0]["type"], parts[1]["type"], parts[2]["type"])
}
if u := partVideoURL(parts[2]); !strings.HasPrefix(u, "data:video/mp4;base64,") {
t.Fatalf("data URI vidéo inattendue : %.40q", u)
}
// Montrée, elle n'est plus annoncée comme simple fichier.
if txt := parts[0]["text"].(string); strings.Contains(txt, "clip-test.mp4") || strings.Contains(txt, "ffmpeg") {
t.Fatalf("vidéo vue annoncée comme fichier : %q", txt)
}
}
// Moteur sans vidéo (API externe, Strata, llama.cpp ancien) : il ignorait la
// partie input_video sans erreur et le modèle ne recevait que le texte. La
// vidéo doit alors être annoncée comme fichier, avec la marche à suivre
// (ffmpeg puis see_image), et see_video doit le dire au lieu de charger.
func TestVideoUnsupportedEngine(t *testing.T) {
videoEngine(t, false)
visionOn(t)
engineSees(t, true)
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
vname := "clip-test.mp4"
if err := os.WriteFile(filepath.Join(dir, vname), []byte("fake-mp4-bytes"), 0o600); err != nil {
t.Fatal(err)
}
content := userMessageContent([]attachInfo{{Name: vname, Path: "uploads/" + vname, Size: 14}}, "décris ce clip")
s, ok := content.(string)
if !ok {
t.Fatalf("attendu du texte seul (pas de partie input_video), obtenu %T", content)
}
if !strings.Contains(s, "uploads/"+vname) || !strings.Contains(s, "ffmpeg") || !strings.HasSuffix(s, "décris ce clip") {
t.Fatalf("chemin et consigne ffmpeg attendus : %q", s)
}
if msg, part := toolSeeVideo(filepath.Join(dir, vname)); part != nil || !strings.Contains(msg, "ffmpeg") {
t.Fatalf("see_video doit refuser avec la consigne ffmpeg : %q", msg)
}
// Ni outil annoncé, ni consigne quand ce n'est pas une vidéo.
for _, tl := range EnabledTools(Caps{Agent: true}) {
if tl.Function.Name == "see_video" {
t.Fatal("see_video annoncé alors que le moteur ne lit pas la vidéo")
}
}
if s := attachNote([]attachInfo{{Name: "a.txt", Path: "uploads/a.txt", Size: 1}}); strings.Contains(s, "ffmpeg") {
t.Fatalf("consigne vidéo sur un fichier texte : %q", s)
}
}
// see_video : annoncé avec see_image quand le moteur lit la vidéo.
func TestSeeVideoAnnonce(t *testing.T) {
videoEngine(t, true)
visionOn(t)
has := func(caps Caps) bool {
for _, tl := range EnabledTools(caps) {
if tl.Function.Name == "see_video" {
return true
}
}
return false
}
if !has(Caps{Agent: true}) {
t.Fatal("see_video absent alors que le moteur lit la vidéo")
}
if has(Caps{}) {
t.Fatal("see_video hors du mode agent")
}
if toolCallLabel("see_video", map[string]any{"file": "a.mp4"}) != "a.mp4" {
t.Fatal("libellé de see_video vide : l'outil s'exécuterait sur une chaîne vide")
}
if dedupableTool("see_video") {
t.Fatal("see_video dédupliqué : le rappel rendrait « déjà fait » sans la vidéo")
}
}
// toolSeeVideo : chaque refus nomme son motif ; un mp4 présent est chargé et
// relayé dans un message utilisateur qui nomme le fichier.
func TestToolSeeVideo(t *testing.T) {
videoEngine(t, true)
home := visionOn(t)
engineSees(t, true)
if err := os.MkdirAll(filepath.Join(home, "dossier.mp4"), 0o755); err != nil {
t.Fatal(err)
}
gros := filepath.Join(home, "gros.mp4")
f, _ := os.Create(gros)
_ = f.Truncate(maxVideoBytes + 1)
f.Close()
_ = os.WriteFile(filepath.Join(home, "vide.webm"), nil, 0o600)
cas := []struct{ path, attendu string }{
{"", "chemin de fichier manquant"},
{filepath.Join(home, "notes.txt"), "format non reconnu comme vidéo"},
{filepath.Join(home, "nulle-part.mp4"), "introuvable"},
{filepath.Join(home, "dossier.mp4"), "dossier"},
{gros, "trop lourde"},
{filepath.Join(home, "vide.webm"), "fichier vide"},
}
for _, c := range cas {
msg, part := toolSeeVideo(c.path)
if part != nil || !strings.HasPrefix(msg, "[erreur]") || !strings.Contains(msg, c.attendu) {
t.Errorf("%q : %q, attendu une erreur contenant %q", c.path, msg, c.attendu)
}
}
p := filepath.Join(home, "clip.MOV")
if err := os.WriteFile(p, []byte("fake-mov"), 0o600); err != nil {
t.Fatal(err)
}
msg, part := toolSeeVideo(p)
if part == nil || msg != "[ok] vidéo chargée : clip.MOV" {
t.Fatalf("attendu un [ok] : %q / %v", msg, part)
}
if u := partVideoURL(part); u != "data:video/quicktime;base64,"+base64.StdEncoding.EncodeToString([]byte("fake-mov")) {
t.Fatalf("partie inattendue : %v", part)
}
// Le moteur en service ne voit pas : on le dit au lieu d'envoyer 200 Mio.
engineSees(t, false)
if msg, part := toolSeeVideo(p); part != nil || !strings.Contains(msg, "ne traite pas les images") {
t.Fatalf("moteur aveugle : %q", msg)
}
// Relais : passé par l'API interne comme see_image (toolStepExtras).
_, _, relay := toolStepExtras("see_video", "clip.MOV", toolExecResult{Result: "[ok]", VisionVideo: videoPart([]byte("x"), "video/mp4")})
if relay == nil {
t.Fatal("aucun relais pour see_video")
}
rp := contentParts(relay.Content)
if relay.Role != "user" || rp[0]["text"] != "Vidéo demandée (clip.MOV) :" || rp[1]["type"] != "input_video" {
t.Fatalf("relais inattendu : %#v", relay)
}
}
// Sans projecteur ou sur un poste distant : refus explicite, avant toute
// question au moteur.
func TestSeeVideoSansVision(t *testing.T) {
videoEngine(t, true)
testHome(t)
if msg, part := toolSeeVideo("a.mp4"); part != nil || !strings.Contains(msg, "MMPROJ") {
t.Fatalf("sans vision : %q", msg)
}
}
// videoInputSupported : jamais pour une API externe, Strata, un preset sans
// projecteur ou sans moteur ; sinon, l'aide du binaire fait foi.
func TestVideoInputSupportedPresets(t *testing.T) {
testHome(t)
cas := []map[string]string{
{"BIN": "llama-server", "MODEL": "m.gguf"}, // pas de projecteur
{"MMPROJ": "mm.gguf", "MODEL": "m.gguf"}, // pas de moteur
{"MMPROJ": "mm.gguf", "BIN": "introuvable/llama-server"}, // moteur absent
{"MMPROJ": "mm.gguf", extKeyFlag: "1", extKeyVision: "1"}, // API externe
{"MMPROJ": "mm.gguf", "ENGINE": engineStrata, "BIN": "/bin/sh", "MODEL": "m"}, // Strata
}
for _, cfg := range cas {
if err := WriteConfig(cfg); err != nil {
t.Fatal(err)
}
if videoInputSupported() {
t.Errorf("%v : vidéo annoncée", cfg)
}
}
}
// L'aide du binaire décide, relue quand le binaire change (mise à jour en place).
func TestBinHelpHasVideo(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("faux moteur en sh")
}
dir := t.TempDir()
bin := filepath.Join(dir, "llama-server")
write := func(help string, mt time.Time) {
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho '"+help+"'\n"), 0o755); err != nil {
t.Fatal(err)
}
_ = os.Chtimes(bin, mt, mt)
}
write("--mmproj FILE", time.Now().Add(-time.Hour))
if binHelpHasVideo(bin) {
t.Fatal("moteur sans --video-fps annoncé comme lisant la vidéo")
}
write("--video-fps N target fps", time.Now())
if !binHelpHasVideo(bin) {
t.Fatal("moteur mis à jour : --video-fps non relu")
}
}
// Compaction, fil rechargé, export : la vidéo n'est jamais rejouée ni
// recopiée, mais sa perte reste visible.
func TestVideoPerdueVisible(t *testing.T) {
part := videoPart([]byte(strings.Repeat("v", 3000)), "video/mp4")
m := Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "regarde"}, part}}
if got := msgText(m); got != "regarde"+videoLostMarker {
t.Fatalf("msgText : %q", got)
}
var reread []Message
b, _ := json.Marshal([]Message{m})
_ = json.Unmarshal(b, &reread)
out := stripImageParts(reread)
if s, ok := out[0].Content.(string); !ok || s != "regarde"+videoLostMarker {
t.Fatalf("fil rechargé : %#v", out[0].Content)
}
exp := stripInlineImages([]Message{m})
js, _ := json.Marshal(exp)
if len(js) > 400 || !strings.Contains(string(js), "élidé") {
t.Fatalf("export : %s", js)
}
if partVideoURL(contentParts(m.Content)[1]) == "" || !strings.HasPrefix(partVideoURL(part), "data:video/mp4;base64,") {
t.Fatal("l'export a touché le message vivant")
}
}
// Le ménage des médias vide aussi les vidéos jointes trop vieilles.
func TestPruneChatVideos(t *testing.T) {
testHome(t)
ref, err := storeChatVideo([]byte("vieille"), "video/webm")
if err != nil || !strings.HasSuffix(ref, ".webm") {
t.Fatalf("rangement : %q %v", ref, err)
}
p := filepath.Join(chatVidDir(), strings.TrimPrefix(ref, vidRefScheme))
old := time.Now().Add(-2 * chatImgKeep)
_ = os.Chtimes(p, old, old)
pruneChatImages()
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatal("vieille vidéo gardée")
}
}
+91
View File
@@ -0,0 +1,91 @@
package loki
// chat_video_tool.go — l'outil see_video (repris d'AJEAN) : le modèle charge
// lui-même un fichier vidéo du disque dans sa VISION, sans que l'utilisateur ait
// à le joindre. Même mécanique que see_image (chat_vision_tool.go) : le résultat
// de l'outil reste un accusé texte, la vidéo part juste après dans un message
// utilisateur multimodal (partie input_video), que llama-server décode en
// images avec ffmpeg. Toujours ÉPHÉMÈRE : jamais dans l'historique.
//
// Annoncé seulement quand le moteur sait lire la vidéo (videoInputSupported) ;
// appelé quand même, il le dit avec la marche à suivre (ffmpeg puis see_image).
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// toolSeeVideo lit un fichier vidéo et renvoie (accusé texte, partie input_video).
// La partie vaut nil en cas d'erreur : l'appelant n'injecte alors rien. Même
// ordre de refus que toolSeeImage, la sonde du moteur en dernier.
func toolSeeVideo(path string) (string, map[string]any) {
if !visionEnabled() {
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo", nil
}
if agentTargetSlug() != "" {
return "[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
}
if path == "" {
return "[erreur] chemin de fichier manquant", nil
}
if !videoInputSupported() {
return "[erreur] " + strings.TrimSuffix(videoFallbackNote, "\n"), nil
}
abs := resolveAgentPath(path)
mime := videoMime(abs)
if mime == "" {
return "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : " + path, nil
}
st, err := os.Stat(abs)
if err != nil {
return "[erreur] fichier introuvable : " + path, nil
}
if st.IsDir() {
return "[erreur] c'est un dossier, pas une vidéo : " + path, nil
}
if st.Size() > maxVideoBytes {
return fmt.Sprintf("[erreur] vidéo trop lourde (%s, max %s) — coupe-la en un clip plus court : %s",
humanBytes(st.Size()), humanBytes(maxVideoBytes), path), nil
}
if !engineSeesImages() {
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
}
b, err := os.ReadFile(abs)
if err != nil {
return "[erreur] lecture impossible : " + err.Error(), nil
}
if len(b) == 0 {
return "[erreur] fichier vide : " + path, nil
}
return "[ok] vidéo chargée : " + filepath.Base(abs), videoPart(b, mime)
}
// seeVideoMessage emballe la partie vidéo dans le message utilisateur qui la
// porte jusqu'au modèle ; la légende nomme le fichier (voir seeImageMessage).
func seeVideoMessage(label string, vid map[string]any) Message {
text := "Vidéo :"
if label != "" {
text = "Vidéo demandée (" + label + ") :"
}
return Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": text},
vid,
}}
}
// seeVideoTool — schéma envoyé au modèle, tenu court comme seeImageTool.
func seeVideoTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "see_video",
Description: "Ouvre un fichier vidéo du disque pour la REGARDER (mp4, webm, mov, mkv, avi). Clips courts.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Chemin de la vidéo (relatif au dossier de travail, ou absolu)"},
},
"required": []string{"file"},
},
}}
}
+75 -19
View File
@@ -16,6 +16,7 @@ import (
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
)
@@ -40,39 +41,94 @@ var defaultRetryPatterns = []string{
// Compilés à la première utilisation, pas à l'init du package : la surcharge
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
// choisi son rôle (CLI, serveur…).
//
// Relus quand la surcharge change (OpenFox c52c5bf7) : un sync.Once les
// figeait jusqu'au redémarrage du process. retryPatterns tourne pendant le flux,
// à chaque morceau : la base n'est rouverte que si une écriture de ce process a
// touché le bucket d'état (retryStateBust, via cacheBust) ou, au plus une fois
// par seconde, si date ou taille du fichier trahissent l'écriture d'un autre
// process (`loki config set` pendant que le service tourne).
var (
retryOnce sync.Once
retryRes []*regexp.Regexp
retryMu sync.Mutex
retryRes []*regexp.Regexp
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
retryCustom bool
retryCustom bool
retryLoaded bool
retryStale bool // écriture locale du bucket d'état depuis la lecture
retryRaw string
retryPath string // la base lue (LOKI_HOME change d'un test à l'autre)
retryChecked time.Time
retryMtime time.Time
retrySize int64
)
func retryPatterns() []*regexp.Regexp {
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
return retryRes
// retryStateBust : appelé par cacheBust pour toute écriture du bucket d'état.
func retryStateBust() {
retryMu.Lock()
retryStale = true
retryMu.Unlock()
}
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
pats := defaultRetryPatterns
custom := false
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
if raw := getStr(bkState, "retry_patterns"); raw != "" {
var over []string
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
pats, custom = over, true
func retryPatterns() []*regexp.Regexp {
res, _ := retryState()
return res
}
// retryState renvoie les motifs compilés et si ce sont ceux de la surcharge.
func retryState() ([]*regexp.Regexp, bool) {
retryMu.Lock()
defer retryMu.Unlock()
reload := !retryLoaded || retryStale || retryPath != dbPath()
if !reload && time.Since(retryChecked) >= time.Second {
retryChecked = time.Now()
if mtime, size := dbStamp(); size != retrySize || !mtime.Equal(retryMtime) {
reload = true
}
}
if reload {
raw := getStr(bkState, "retry_patterns")
retryMtime, retrySize = dbStamp()
retryChecked, retryStale, retryPath = time.Now(), false, dbPath()
if !retryLoaded || raw != retryRaw {
retryRes, retryCustom = compileRetryPatterns(raw)
retryRaw, retryLoaded = raw, true
}
}
return retryRes, retryCustom
}
// compileRetryPatterns compile la surcharge raw (tableau JSON de regex), ou
// les motifs embarqués quand elle est absente, illisible, ou sans un seul motif
// utilisable. Un motif vide est écarté : "(?mi)" correspond à TOUT, et chaque
// tour était relancé une fois (OpenFox 8e9b4869).
func compileRetryPatterns(raw string) ([]*regexp.Regexp, bool) {
// Surcharge optionnelle : clé d'état `retry_patterns`. Pas d'UI dédiée —
// c'est un réglage d'expert, posé via `loki config` ou l'API ; l'embarqué
// couvre les cas connus.
if raw != "" {
var over []string
if json.Unmarshal([]byte(raw), &over) == nil {
if out := compileRetryList(over); len(out) > 0 {
return out, true
}
}
}
return compileRetryList(defaultRetryPatterns), false
}
func compileRetryList(pats []string) []*regexp.Regexp {
var out []*regexp.Regexp
for _, p := range pats {
if strings.TrimSpace(p) == "" {
continue
}
if re, err := regexp.Compile("(?mi)" + p); err == nil {
out = append(out, re)
}
}
return out, custom
return out
}
// textualToolCall dit si le texte final du tour contient un appel d'outil
@@ -100,9 +156,9 @@ func textualToolCallFrom(content string, scanned int) bool {
if content == "" {
return false
}
res := retryPatterns()
res, custom := retryState()
start := 0
if !retryCustom {
if !custom {
start = retryScanStart(content, scanned)
}
w := content[start:]
+47
View File
@@ -0,0 +1,47 @@
package loki
import "testing"
// Un motif vide dans la surcharge correspondait à tout : chaque tour était
// relancé une fois. Il est écarté ; une surcharge sans motif utilisable retombe
// sur les motifs embarqués.
func TestCompileRetryPatternsSkipsEmpty(t *testing.T) {
res, custom := compileRetryPatterns(`["", " ", "<oups>"]`)
if !custom || len(res) != 1 {
t.Fatalf("custom=%v, %d motifs ; attendu la surcharge avec un seul motif", custom, len(res))
}
if res[0].MatchString("une réponse tout à fait normale") {
t.Fatal("un texte ordinaire déclenche une relance")
}
res, custom = compileRetryPatterns(`["", "([invalide"]`)
if custom || len(res) != len(defaultRetryPatterns) {
t.Fatalf("custom=%v, %d motifs ; attendu les motifs embarqués", custom, len(res))
}
if _, custom = compileRetryPatterns(`pas du json`); custom {
t.Fatal("surcharge illisible prise en compte")
}
}
// Un changement de retry_patterns agit sans redémarrer le process.
func TestRetryPatternsReloadOnChange(t *testing.T) {
testHome(t)
retryStateBust()
if _, custom := retryState(); custom {
t.Fatal("surcharge active sans clé")
}
if err := putStr(bkState, "retry_patterns", `["OUPS-OUTIL"]`); err != nil {
t.Fatal(err)
}
if !textualToolCall("bon, OUPS-OUTIL, voilà") {
t.Fatal("nouveau motif ignoré après l'écriture")
}
if err := putStr(bkState, "retry_patterns", ""); err != nil {
t.Fatal(err)
}
if _, custom := retryState(); custom {
t.Fatal("surcharge retirée mais toujours active")
}
if textualToolCall("bon, OUPS-OUTIL, voilà") {
t.Fatal("ancien motif encore appliqué")
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
package loki
import (
"context"
"encoding/json"
"errors"
"io"
@@ -857,10 +858,10 @@ func TestFacadeMCP(t *testing.T) {
if len(tools) != 1 || tools[0].Function.Name != "mcp__a__t" || mcpPromptLine(tools) == "" {
t.Fatalf("outils : %+v", tools)
}
if got := mcpCall("mcp__a__t", map[string]any{"x": 1}); got != "fait" {
if got := mcpCall(context.Background(), "mcp__a__t", map[string]any{"x": 1}); got != "fait" {
t.Fatalf("appel : %q", got)
}
if got := mcpCall("mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
if got := mcpCall(context.Background(), "mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
t.Fatalf("appel sans arguments : %q (%v)", got, calls)
}
MCPPrewarm()
@@ -872,7 +873,7 @@ func TestFacadeMCP(t *testing.T) {
}
// Façade injoignable : une erreur lisible, pas de panique.
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if got := mcpCall("mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
if got := mcpCall(context.Background(), "mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
t.Fatalf("façade injoignable : %q", got)
}
if tools := mcpTools(); tools != nil {
+37 -13
View File
@@ -515,6 +515,12 @@ func EnabledTools(caps Caps) []Tool {
// pas — il promettrait alors de regarder, puis se contredirait.
if caps.Agent && visionEnabled() {
tools = append(tools, seeImageTool())
// Voir une vidéo : seulement si le moteur sait la lire (llama.cpp local
// avec --video-fps, chat_video.go). Ailleurs, la vidéo resterait lettre
// morte : Strata et les API externes ignorent la partie sans erreur.
if videoInputSupported() {
tools = append(tools, seeVideoTool())
}
}
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
tools = append(tools, webScreenshotTool())
@@ -670,12 +676,12 @@ func fillToolResult(ev *ToolUsedEvent, result string) *ToolUsedEvent {
// - leur résultat dépend de l'ÉTAT VIVANT de la page (un browser_snapshot sans
// argument a forcément la même clé de dédup à chaque appel, alors que la page
// a changé) — les dédupliquer fige l'IA sur un vieux cliché ;
// - surtout, browser_screenshot et see_image portent leur IMAGE dans un message
// - surtout, browser_screenshot et see_image (see_video : sa vidéo) portent leur IMAGE dans un message
// à part (visionImg) que le chemin de dédup ne rejoue PAS : l'IA recevrait
// « [déjà fait] » SANS l'image et tournerait en boucle.
func dedupableTool(name string) bool {
switch name {
case "bash", "bash_bg", "bash_tail", "see_image":
case "bash", "bash_bg", "bash_tail", "see_image", "see_video":
return false
}
return !strings.HasPrefix(name, "browser_")
@@ -1121,7 +1127,7 @@ func toolCallLabel(name string, args map[string]any) string {
switch name {
case "mem_search", "web_search", "web_images", "recall_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image", "see_video":
label, _ = args["file"].(string)
case "recall":
label, _ = args["id"].(string)
@@ -1221,9 +1227,10 @@ type chatPayloadOpts struct {
func buildChatPayload(ep chatEndpoint, sent []Message, temperature float64, o chatPayloadOpts) map[string]any {
payload := map[string]any{
"model": ep.Model,
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(sent),
// Les images et vidéos de l'historique y sont rangées par référence
// (chat_images.go, chat_video.go) : on remet leurs octets juste avant
// l'envoi.
"messages": expandVideoRefs(expandImageRefs(sent)),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
@@ -1373,6 +1380,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
// Contexte estimé pour la requête suivante (en-tour), comparé au compte
// réel du moteur quand il arrive — journalisé s'il s'en écarte.
lastEst := 0
// Compaction en cours de tour qui n'a rien pu réduire : la retenter
// seulement une fois le contexte nettement plus gros (compactRetryDue).
compactRetryAt := 0
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
patternRetries := 0
@@ -2340,7 +2350,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
var diffAdd, diffDel int // vrais totaux (diff est tronqué pour l'UI)
// visionImg : partie image_url rendue par see_image, réinjectée après
// le résultat de l'outil (un message `tool` ne porte que du texte).
var visionImg map[string]any
var visionImg, visionVideo map[string]any
// Appel rigoureusement identique déjà exécuté dans ce tour : on ne le
// rejoue pas. Les petits modèles réémettent volontiers deux fois la
// même écriture ; la rejouer produisait une fausse erreur (« old
@@ -2372,7 +2382,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
} else {
ex = execTool(ctx, tc.Function.Name, args, label, caps)
}
result, diff, diffAdd, diffDel, visionImg = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg
result, diff, diffAdd, diffDel, visionImg, visionVideo = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg, ex.VisionVideo
if ex.Ask != nil {
cb(StreamEvent{Ask: ex.Ask})
}
@@ -2430,6 +2440,13 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
if visionImg != nil {
messages, extra = kt.relay(messages, extra, seeImageMessage(label, visionImg), stepStart)
}
// see_video a réussi : même relais, toujours ÉPHÉMÈRE — dans
// `messages` seulement, jamais dans `extra`, KEEP_TURN_IMAGES
// compris (une vidéo pèse des milliers de jetons visuels, et
// kt.relay ne sait ranger que des images).
if visionVideo != nil {
messages = append(messages, seeVideoMessage(label, visionVideo))
}
}
// Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour :
// une boucle d'outils peut à elle seule remplir la fenêtre (résultats
@@ -2468,7 +2485,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
publishHistory()
}
}
if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used, messages) {
if compactNeeded(messages, used, peakGen) && compactRetryDue(compactRetryAt, used, messages) &&
!compactRefusedSkip(mainConv, used, messages) {
opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol,
disableTools || toolChoiceNone)
yes, no := true, false
@@ -2476,6 +2494,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
c, changed := compactMessagesNoted(ctx, messages, caps, opt)
cb(StreamEvent{Compacting: &no})
logCompact("en-tour", used, messages, c, changed)
compactRetryAt = compactNextRetry(changed, used, messages)
if changed {
lastEst = 0 // estimation d'avant compaction : plus rien à comparer
messages = c
@@ -2611,7 +2630,10 @@ type toolExecResult struct {
Added int `json:"added,omitempty"`
Removed int `json:"removed,omitempty"`
VisionImg map[string]any `json:"vision_img,omitempty"`
Ask *AskEvent `json:"ask,omitempty"`
// VisionVideo : partie input_video rendue par see_video (chat_video_tool.go),
// relayée au modèle comme l'image, mais toujours éphémère.
VisionVideo map[string]any `json:"vision_video,omitempty"`
Ask *AskEvent `json:"ask,omitempty"`
}
// execToolHook : nil en service. Les tests de parité Go/Rust
@@ -2626,11 +2648,13 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
result := ""
var diff []DiffLine
var diffAdd, diffDel int
var visionImg map[string]any
var visionImg, visionVideo map[string]any
var ask *AskEvent
switch name {
case "see_image":
result, visionImg = toolSeeImage(label)
case "see_video":
result, visionVideo = toolSeeVideo(label)
case "recall":
result = toolRecall(args)
case "recall_search":
@@ -2810,10 +2834,10 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
result, visionImg = toolCUScreenshot()
default:
if isMCPTool(name) {
result = mcpCall(name, args)
result = mcpCall(ctx, name, args)
} else {
result = "[erreur] outil inconnu: " + name
}
}
return toolExecResult{Result: result, Diff: diff, Added: diffAdd, Removed: diffDel, VisionImg: visionImg, Ask: ask}
return toolExecResult{Result: result, Diff: diff, Added: diffAdd, Removed: diffDel, VisionImg: visionImg, VisionVideo: visionVideo, Ask: ask}
}
+85 -23
View File
@@ -25,6 +25,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -73,6 +74,14 @@ const (
lspMaxDiags = 10 // diagnostics montrés au modèle
)
// lspDiagQuiet : calme attendu après une publication avant de la croire
// définitive. gopls, rust-analyzer et pyright publient souvent en plusieurs
// fois pour une même version (liste vide d'abord, puis leur analyse, puis
// celle d'un outil externe) : rendre la PREMIÈRE donnait « propre » à une
// édition qui cassait la compilation. OpenFox attend 400 ms ; variable pour
// les tests.
var lspDiagQuiet = 350 * time.Millisecond
type lspServer struct {
lang string
root string
@@ -82,11 +91,17 @@ type lspServer struct {
nextID int
version map[string]int // uri → version de document
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
diagMu sync.Mutex
diags map[string][]lspDiagnostic
diagMu sync.Mutex
diags map[string][]lspDiagnostic
// diagVer : version de document annoncée par la dernière publication de
// l'uri, -1 quand le serveur n'en donne pas (champ facultatif du protocole).
diagVer map[string]int
diagCh chan string // uri fraîchement publié
lastUsed time.Time
dead bool
// dead : atomique, pas sous mu. writeMsg le pose sur une écriture ratée, et
// writeMsg tourne DANS check, qui tient déjà mu : le prendre là bloquait le
// serveur (et le tour) à vie. lspGet le lit aussi sans mu.
dead atomic.Bool
}
type lspDiagnostic struct {
@@ -214,7 +229,7 @@ func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
key := lang.ID + "\x00" + root
lspMu.Lock()
defer lspMu.Unlock()
if s := lspServers[key]; s != nil && !s.dead {
if s := lspServers[key]; s != nil && !s.dead.Load() {
return s, nil
}
s, err := lspStart(lang, root)
@@ -242,7 +257,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
}
s := &lspServer{
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
version: map[string]int{}, diags: map[string][]lspDiagnostic{},
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16), lastUsed: time.Now(),
}
go s.readLoop(stdout)
@@ -268,7 +283,7 @@ func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.dead {
if s.dead.Load() {
return nil, false
}
s.lastUsed = time.Now()
@@ -277,16 +292,29 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
return nil, false
}
uri := fileURI(absPath)
// Signaux en attente : tous PÉRIMÉS. Un serveur qui republie après le
// retour du check précédent laisse son uri dans le tampon ; lu ici tout de
// suite après didChange, il faisait rendre « aucun diagnostic » avant même
// que le serveur ait vu la nouvelle version.
for drained := false; !drained; {
select {
case <-s.diagCh:
default:
drained = true
}
}
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
s.diagMu.Lock()
delete(s.diags, uri)
delete(s.diagVer, uri)
s.diagMu.Unlock()
v, open := s.version[uri]
if !open {
s.version[uri] = 1
v = 1
s.version[uri] = v
s.notify("textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": s.lang, "version": 1, "text": string(content),
"uri": uri, "languageId": s.lang, "version": v, "text": string(content),
},
})
} else {
@@ -297,24 +325,56 @@ func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
"contentChanges": []map[string]any{{"text": string(content)}},
})
}
// Attend la publication pour CET uri, bornée.
deadline := time.After(lspDiagWait)
if s.dead.Load() {
return nil, false // écriture ratée : le serveur ne verra jamais cette version
}
// current : les diagnostics publiés pour CETTE version (ou sans version),
// pas ceux d'une version antérieure arrivés en retard.
current := func() ([]lspDiagnostic, bool) {
s.diagMu.Lock()
defer s.diagMu.Unlock()
d, ok := s.diags[uri]
if !ok {
return nil, false
}
if pv, has := s.diagVer[uri]; has && pv >= 0 && pv < v {
return nil, false
}
return d, true
}
// Attend la publication pour CET uri, puis un calme de lspDiagQuiet sans
// nouvelle publication ; le tout borné par lspDiagWait.
deadline := time.NewTimer(lspDiagWait)
defer deadline.Stop()
var quiet <-chan time.Time
var quietT *time.Timer
defer func() {
if quietT != nil {
quietT.Stop()
}
}()
for {
select {
case got := <-s.diagCh:
if got != uri {
continue
}
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
if _, ok := current(); !ok {
continue // publication d'une version antérieure
}
if quietT == nil {
quietT = time.NewTimer(lspDiagQuiet)
} else {
quietT.Reset(lspDiagQuiet)
}
quiet = quietT.C
case <-quiet:
d, _ := current()
return d, true
case <-deadline:
case <-deadline.C:
// Pas de publication à temps : certains serveurs ne publient rien
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
d, _ := current()
return d, true
}
}
@@ -353,6 +413,7 @@ func (s *lspServer) readLoop(r io.Reader) {
Method string `json:"method"`
Params struct {
URI string `json:"uri"`
Version *int `json:"version"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
} `json:"params"`
}
@@ -362,6 +423,11 @@ func (s *lspServer) readLoop(r io.Reader) {
if msg.Method == "textDocument/publishDiagnostics" {
s.diagMu.Lock()
s.diags[msg.Params.URI] = msg.Params.Diagnostics
ver := -1
if msg.Params.Version != nil {
ver = *msg.Params.Version
}
s.diagVer[msg.Params.URI] = ver
s.diagMu.Unlock()
select {
case s.diagCh <- msg.Params.URI:
@@ -377,7 +443,7 @@ func (s *lspServer) idleWatch() {
for range t.C {
s.mu.Lock()
idle := time.Since(s.lastUsed) > lspIdleTimeout
dead := s.dead
dead := s.dead.Load()
s.mu.Unlock()
if dead {
return
@@ -401,11 +467,7 @@ func (s *lspServer) shutdown() {
lspMu.Unlock()
}
func (s *lspServer) markDead() {
s.mu.Lock()
s.dead = true
s.mu.Unlock()
}
func (s *lspServer) markDead() { s.dead.Store(true) }
func (s *lspServer) send(method string, params any, isRequest bool) {
s.nextID++
+200
View File
@@ -0,0 +1,200 @@
package loki
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fakeLSP : un serveur LSP réduit à ce que check utilise. Pour chaque
// didOpen/didChange reçu, react(version, publish) décide des publications.
type fakeLSP struct {
s *lspServer
out *io.PipeWriter
}
func lspFrame(w io.Writer, v any) {
b, _ := json.Marshal(v)
fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(b), b)
}
func newFakeLSP(t *testing.T, react func(ver int, publish func(ver *int, diags []lspDiagnostic))) *fakeLSP {
t.Helper()
inR, inW := io.Pipe()
outR, outW := io.Pipe()
s := &lspServer{
lang: "go", root: t.TempDir(), stdin: inW,
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16), lastUsed: time.Now(),
}
go s.readLoop(outR)
go func() {
br := bufio.NewReader(inR)
for {
n := 0
for {
line, err := br.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
break
}
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
}
}
buf := make([]byte, n)
if _, err := io.ReadFull(br, buf); err != nil {
return
}
var msg struct {
Method string `json:"method"`
Params struct {
TextDocument struct {
URI string `json:"uri"`
Version int `json:"version"`
} `json:"textDocument"`
} `json:"params"`
}
if json.Unmarshal(buf, &msg) != nil {
continue
}
if msg.Method != "textDocument/didOpen" && msg.Method != "textDocument/didChange" {
continue
}
uri := msg.Params.TextDocument.URI
go react(msg.Params.TextDocument.Version, func(ver *int, diags []lspDiagnostic) {
params := map[string]any{"uri": uri, "diagnostics": diags}
if ver != nil {
params["version"] = *ver
}
lspFrame(outW, map[string]any{"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": params})
})
}
}()
t.Cleanup(func() { inW.Close(); outW.Close() })
return &fakeLSP{s: s, out: outW}
}
func lspErr(msg string) []lspDiagnostic {
d := lspDiagnostic{Severity: 1, Message: msg}
return []lspDiagnostic{d}
}
func lspTempFile(t *testing.T) string {
t.Helper()
p := filepath.Join(t.TempDir(), "main.go")
if err := os.WriteFile(p, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func withLSPQuiet(t *testing.T, d time.Duration) {
old := lspDiagQuiet
lspDiagQuiet = d
t.Cleanup(func() { lspDiagQuiet = old })
}
// Un signal laissé dans le tampon par une republication tardive ne doit pas
// faire rendre « aucun diagnostic » à l'édition suivante.
func TestLSPCheckIgnoresStaleSignal(t *testing.T) {
withLSPQuiet(t, 80*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
time.Sleep(60 * time.Millisecond)
publish(&ver, lspErr("undefined: x"))
})
uri := fileURI(p)
f.s.version[uri] = 1 // document déjà ouvert
f.s.diagCh <- uri // republication tardive du check précédent
f.s.diags[uri] = nil // …dont la liste était vide
d, ok := f.s.check(p)
if !ok || len(d) != 1 || d[0].Message != "undefined: x" {
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur de la nouvelle version", d, ok)
}
}
// Une liste vide publiée d'abord (« clear ») puis l'analyse : c'est l'analyse
// qui compte.
func TestLSPCheckWaitsPastInitialClear(t *testing.T) {
withLSPQuiet(t, 150*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
publish(&ver, nil)
time.Sleep(60 * time.Millisecond)
publish(&ver, lspErr("missing return"))
})
d, ok := f.s.check(p)
if !ok || len(d) != 1 || d[0].Message != "missing return" {
t.Fatalf("diagnostics = %+v, %v ; attendu l'erreur publiée après la liste vide", d, ok)
}
}
// Une publication d'une version antérieure, arrivée en retard, est ignorée.
func TestLSPCheckIgnoresOlderVersion(t *testing.T) {
withLSPQuiet(t, 80*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
old := ver - 1
publish(&old, lspErr("ancienne erreur"))
time.Sleep(40 * time.Millisecond)
publish(&ver, nil)
})
f.s.version[fileURI(p)] = 3
d, ok := f.s.check(p)
if !ok || len(d) != 0 {
t.Fatalf("diagnostics = %+v, %v ; attendu aucun (seule la version 4 compte)", d, ok)
}
}
// Sans version dans la publication (champ facultatif), elle est prise.
func TestLSPCheckAcceptsUnversionedPublish(t *testing.T) {
withLSPQuiet(t, 50*time.Millisecond)
p := lspTempFile(t)
f := newFakeLSP(t, func(ver int, publish func(*int, []lspDiagnostic)) {
publish(nil, lspErr("syntax error"))
})
start := time.Now()
d, ok := f.s.check(p)
if !ok || len(d) != 1 {
t.Fatalf("diagnostics = %+v, %v", d, ok)
}
if time.Since(start) > lspDiagWait {
t.Fatalf("check a attendu la borne au lieu du calme")
}
}
// Une écriture ratée vers le serveur ne bloque plus check (markDead prenait
// mu, déjà tenu par check).
func TestLSPCheckWriteFailureDoesNotDeadlock(t *testing.T) {
p := lspTempFile(t)
r, w := io.Pipe()
r.Close() // toute écriture échoue
s := &lspServer{
lang: "go", stdin: w,
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
diagCh: make(chan string, 16),
}
done := make(chan struct{})
go func() {
s.check(p)
close(done)
}()
select {
case <-done:
case <-time.After(lspDiagWait + 2*time.Second):
t.Fatal("check bloqué après une écriture ratée")
}
if !s.dead.Load() {
t.Fatal("serveur pas marqué mort après une écriture ratée")
}
}
+96
View File
@@ -0,0 +1,96 @@
package loki
import (
"context"
"strings"
"testing"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// fakeMCPSlow branche un serveur MCP en mémoire dont l'outil « slow » ne rend
// la main qu'à l'annulation de sa requête (ou au bout de 30 s).
func fakeMCPSlow(t *testing.T) string {
t.Helper()
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "lent", Version: "1"}, nil)
srv.AddTool(&mcpsdk.Tool{Name: "slow", InputSchema: map[string]any{"type": "object"}},
func(ctx context.Context, _ *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(30 * time.Second):
return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "fini"}}}, nil
}
})
st, ct := mcpsdk.NewInMemoryTransports()
ctx := context.Background()
ss, err := srv.Connect(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
cs, err := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "loki-test", Version: "1"}, nil).Connect(ctx, ct, nil)
if err != nil {
t.Fatal(err)
}
ready := make(chan struct{})
close(ready)
name := mcpExposedName("lent", "slow")
mcpMgr.mu.Lock()
if mcpMgr.sessions == nil {
mcpMgr.sessions = map[string]*mcpSession{}
}
if mcpMgr.registry == nil {
mcpMgr.registry = map[string]mcpToolRef{}
}
mcpMgr.sessions["lent"] = &mcpSession{name: "lent", sess: cs, ready: ready}
mcpMgr.registry[name] = mcpToolRef{server: "lent", tool: "slow"}
mcpMgr.mu.Unlock()
t.Cleanup(func() {
mcpMgr.mu.Lock()
delete(mcpMgr.sessions, "lent")
delete(mcpMgr.registry, name)
mcpMgr.mu.Unlock()
cs.Close()
ss.Close()
})
return name
}
// Le bouton stop annule l'appel MCP en cours au lieu d'attendre ses 120 s.
func TestMCPCallHonorsTurnCancel(t *testing.T) {
testHome(t)
name := fakeMCPSlow(t)
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(150*time.Millisecond, cancel)
start := time.Now()
out := mcpCall(ctx, name, map[string]any{})
if d := time.Since(start); d > 5*time.Second {
t.Fatalf("l'appel a ignoré l'annulation du tour (%s)", d)
}
if !strings.Contains(out, "interrompu") {
t.Fatalf("sortie = %q ; attendu un appel interrompu", out)
}
}
func TestMCPCallTimeoutFor(t *testing.T) {
cases := []struct {
cfg MCPServerConfig
args map[string]any
want time.Duration
}{
{MCPServerConfig{}, nil, mcpCallTimeout},
{MCPServerConfig{CallTimeoutSec: 600}, nil, 10 * time.Minute},
{MCPServerConfig{}, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes
{MCPServerConfig{}, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes
{MCPServerConfig{}, map[string]any{"timeout": 5000.0}, mcpCallTimeout}, // 5 s : le défaut reste
{MCPServerConfig{}, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne
{MCPServerConfig{}, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond
{MCPServerConfig{CallTimeoutSec: 30}, nil, 30 * time.Second}, // plus court que le défaut
}
for i, c := range cases {
if got := mcpCallTimeoutFor(c.cfg, c.args); got != c.want {
t.Errorf("cas %d : %s, attendu %s", i, got, c.want)
}
}
}
+69 -7
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -374,7 +375,11 @@ func isMCPTool(name string) bool {
// mcpCall exécute un outil MCP à partir de son nom exposé et renvoie une chaîne
// résultat (texte aplati), cappée. Reconnecte une fois si la session est morte.
func mcpCall(name string, args map[string]any) string {
//
// parent est le contexte DU TOUR, comme pour runShell : l'appel naissait d'un
// context.Background(), donc le bouton stop n'arrêtait rien et le tour restait
// bloqué jusqu'au bout des 120 s (OpenFox 2ef584b8).
func mcpCall(parent context.Context, name string, args map[string]any) string {
if facadeMCP() { // le pool est celui de la façade (facade_mcp.go)
return facadeMCPCall(name, args)
}
@@ -384,35 +389,92 @@ func mcpCall(name string, args map[string]any) string {
if !ok {
return "[erreur] outil MCP inconnu ou serveur non connecté: " + name
}
servers, _ := LoadMCPConfig()
cfg, exists := servers[ref.server]
timeout := mcpCallTimeoutFor(cfg, args)
result, err := mcpCallOnce(ref, args)
result, err := mcpCallOnce(parent, ref, args, timeout)
if err != nil {
if parent.Err() != nil {
return "[appel MCP interrompu]"
}
if errors.Is(err, context.DeadlineExceeded) {
// Le serveur est vivant mais l'outil est lent : le relancer
// doublerait l'attente (et l'effet de l'outil).
return fmt.Sprintf("[erreur MCP] pas de réponse après %s (délai de l'appel ; un argument timeout plus grand l'allonge)", timeout.Round(time.Second))
}
// Session peut-être morte (process stdio tombé) : on l'invalide et on
// retente une fois avec une reconnexion fraîche.
servers, _ := LoadMCPConfig()
cfg, exists := servers[ref.server]
if exists && cfg.Enabled {
mcpInvalidate(ref.server)
mcpMgr.ensure(ref.server, cfg)
if result2, err2 := mcpCallOnce(ref, args); err2 == nil {
if result2, err2 := mcpCallOnce(parent, ref, args, timeout); err2 == nil {
return result2
} else {
err = err2
}
if parent.Err() != nil {
return "[appel MCP interrompu]"
}
}
return "[erreur MCP] " + err.Error()
}
return result
}
func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
// mcpCallTimeoutMax plafonne le délai d'un appel, quoi que demandent l'outil
// ou la config : au-delà, c'est un job d'arrière-plan, pas un appel d'outil.
const mcpCallTimeoutMax = 30 * time.Minute
// mcpCallTimeoutFor : délai d'un appel. Défaut mcpCallTimeout, remplacé par
// CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même
// (beaucoup en ont un : navigateur, build, requête) l'allonge de 30 s pour que
// l'outil ait le temps de rendre SA propre erreur de délai.
func mcpCallTimeoutFor(cfg MCPServerConfig, args map[string]any) time.Duration {
d := mcpCallTimeout
if cfg.CallTimeoutSec > 0 {
d = time.Duration(cfg.CallTimeoutSec) * time.Second
}
if t := mcpArgTimeout(args); t > 0 && t+30*time.Second > d {
d = t + 30*time.Second
}
return min(d, mcpCallTimeoutMax)
}
// mcpArgTimeout lit l'argument « timeout » d'un appel. Les serveurs MCP ne
// s'accordent pas sur l'unité : au-delà de 3600 on le lit en millisecondes
// (5000 = 5 s), en deçà en secondes (600 = 10 min).
func mcpArgTimeout(args map[string]any) time.Duration {
var v float64
switch x := args["timeout"].(type) {
case float64:
v = x
case int:
v = float64(x)
case int64:
v = float64(x)
case json.Number:
v, _ = x.Float64()
case string:
v, _ = strconv.ParseFloat(strings.TrimSpace(x), 64)
}
if v <= 0 {
return 0
}
if v > 3600 {
return time.Duration(v) * time.Millisecond
}
return time.Duration(v * float64(time.Second))
}
func mcpCallOnce(parent context.Context, ref mcpToolRef, args map[string]any, timeout time.Duration) (string, error) {
mcpMgr.mu.Lock()
s := mcpMgr.sessions[ref.server]
mcpMgr.mu.Unlock()
if s == nil || s.sess == nil {
return "", fmt.Errorf("serveur %s non connecté", ref.server)
}
ctx, cancel := context.WithTimeout(context.Background(), mcpCallTimeout)
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
res, err := s.sess.CallTool(ctx, &mcpsdk.CallToolParams{Name: ref.tool, Arguments: args})
if err != nil {
+2 -1
View File
@@ -1,6 +1,7 @@
package loki
import (
"context"
"os/exec"
"strings"
"sync"
@@ -83,7 +84,7 @@ func TestMCPEndToEnd(t *testing.T) {
t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools))
}
out := mcpCall(echoName, map[string]any{"message": "bonjour-loki"})
out := mcpCall(context.Background(), echoName, map[string]any{"message": "bonjour-loki"})
if !strings.Contains(out, "bonjour-loki") {
t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out)
}
+5
View File
@@ -50,6 +50,11 @@ type MCPServerConfig struct {
// non namespacé). Permet de garder un serveur connecté tout en masquant
// certains de ses outils. Vide = tous les outils exposés.
DisabledTools []string `json:"disabledTools,omitempty"`
// CallTimeoutSec : délai d'un appel d'outil de ce serveur, en secondes.
// 0 = défaut (mcpCallTimeout). Pour un serveur dont les outils sont longs
// par nature (build, scraping, requête lourde).
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
}
// ToolDisabled indique si un outil (nom réel) est masqué pour ce serveur.
+2 -2
View File
@@ -299,9 +299,9 @@ func TestPariteMCP(t *testing.T) {
results := map[string]string{}
for _, n := range []string{"echo", "multi", "media", "link", "struct", "fail", "empty", "big", "titled", "secret", "dot_tool", "nope"} {
name := mcpExposedName("alpha", n)
results[name] = mcpCall(name, callArgs[n])
results[name] = mcpCall(context.Background(), name, callArgs[n])
}
results["mcp__zeta__x"] = mcpCall("mcp__zeta__x", nil)
results["mcp__zeta__x"] = mcpCall(context.Background(), "mcp__zeta__x", nil)
fx["calls"] = results
fx["prompt_line_live"] = mcpPromptLine(mcpTools())
+197
View File
@@ -0,0 +1,197 @@
package loki
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// parity_video_tool_test.go — journal de référence de l'outil see_video
// (repris d'AJEAN, tenu par la façade comme see_image, lot 45) : refus dans
// le même ordre que see_image, moteur qui ne lit pas la vidéo (consigne
// ffmpeg), sonde « le moteur voit-il » (faux /props) et vidéo relayée au
// modèle, à l'octet (une vidéo n'est jamais réencodée).
//
// La capacité vidéo du moteur (videoInputSupported : aide du binaire) est
// fixée par chaque étape : la façade la calcule de son côté par le même
// chemin (imagetools::video_input_supported) et la passe à see_video. Rangé
// dans rust/loki-rs/tests/parity_images/video.json ; tests/videotools.rs le
// rejoue contre le port.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilVideo
const parityVideoFile = "../../rust/loki-rs/tests/parity_images/video.json"
type parityVideoStep struct {
Name string `json:"name"`
Vision bool `json:"vision,omitempty"`
Sees bool `json:"sees,omitempty"` // le faux /props annonce la vision
Video bool `json:"video,omitempty"` // le moteur lit la vidéo (--video-fps)
Args map[string]any `json:"args"`
Label string `json:"label"`
Result string `json:"result"`
Part map[string]any `json:"part,omitempty"` // vidéo relayée
Probes int `json:"probes"`
}
func TestPariteOutilVideo(t *testing.T) {
testHome(t)
files := filepath.Join(t.TempDir(), "fichiers")
raw := map[string][]byte{
"clip.mp4": []byte("\x00\x00\x00\x18ftypmp42 fausse vidéo"),
"CLIP.MOV": []byte("\x00\x00\x00\x14ftypqt mov"),
"anim.webm": []byte("\x1aE\xdf\xa3 webm"),
"film.mkv": []byte("\x1aE\xdf\xa3 matroska"),
"vieux.avi": []byte("RIFF\x10\x00\x00\x00AVI LIST"),
"sous/court.mp4": []byte("ftyp sous-dossier"),
"texte.txt": []byte("pas une vidéo"),
"vide.mp4": {},
}
for name, b := range raw {
p := filepath.Join(files, name)
_ = os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
}
_ = os.MkdirAll(filepath.Join(files, "dossier.mp4"), 0o755)
lourd, _ := os.Create(filepath.Join(files, "lourd.mp4"))
_ = lourd.Truncate(maxVideoBytes + 1)
lourd.Close()
setTaskWorkspace(files)
t.Cleanup(func() { setTaskWorkspace("") })
// Faux moteur : /props, authentifié.
var mu sync.Mutex
sees, probes := false, 0
eng := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if r.URL.Path != "/props" || r.Header.Get("Authorization") != "Bearer cle-moteur" {
w.WriteHeader(401)
return
}
probes++
_ = json.NewEncoder(w).Encode(map[string]any{"modalities": map[string]any{"vision": sees}})
}))
defer eng.Close()
_ = SetConfigKey("PORT", strings.TrimPrefix(eng.URL, "http://127.0.0.1:"))
_ = putStr(bkState, "api_key", "cle-moteur")
prevVideo := videoInputSupported
t.Cleanup(func() { videoInputSupported = prevVideo })
var steps []parityVideoStep
run := func(st parityVideoStep) {
if st.Vision {
_ = SetConfigKey("MMPROJ", "vision.gguf")
} else {
_ = SetConfigKey("MMPROJ", "")
}
video := st.Video
videoInputSupported = func() bool { return video }
mu.Lock()
sees, probes = st.Sees, 0
mu.Unlock()
visionProbeMu.Lock()
visionProbeAt = time.Time{}
visionProbeMu.Unlock()
args := map[string]any{}
b, _ := json.Marshal(st.Args)
_ = json.Unmarshal([]byte(strings.ReplaceAll(string(b), "{{FICHIERS}}", filepath.ToSlash(files))), &args)
label := toolCallLabel("see_video", args)
st.Label = strings.ReplaceAll(label, filepath.ToSlash(files), "{{FICHIERS}}")
res := execTool(t.Context(), "see_video", args, label, Caps{Agent: true})
_, _, relay := toolStepExtras("see_video", label, res)
st.Result = strings.ReplaceAll(res.Result, filepath.ToSlash(files), "{{FICHIERS}}")
if relay != nil {
for _, p := range contentParts(relay.Content) {
switch p["type"] {
case "text":
st.Result += "\n[relais] " + strings.ReplaceAll(fmt.Sprint(p["text"]), filepath.ToSlash(files), "{{FICHIERS}}")
case "input_video":
st.Part = parityVideoFacts(t, p, raw)
}
}
}
mu.Lock()
st.Probes = probes
mu.Unlock()
if st.Args == nil {
st.Args = map[string]any{}
}
steps = append(steps, st)
}
a := func(f string) map[string]any { return map[string]any{"file": f} }
run(parityVideoStep{Name: "sans vision", Video: true, Args: a("clip.mp4")})
run(parityVideoStep{Name: "chemin manquant", Vision: true, Video: true, Args: a("")})
run(parityVideoStep{Name: "sans argument", Vision: true, Video: true})
run(parityVideoStep{Name: "moteur sans vidéo", Vision: true, Sees: true, Args: a("clip.mp4")})
run(parityVideoStep{Name: "pas une vidéo", Vision: true, Video: true, Args: a("texte.txt")})
run(parityVideoStep{Name: "image", Vision: true, Video: true, Args: a("photo.png")})
run(parityVideoStep{Name: "introuvable", Vision: true, Video: true, Args: a("absente.mp4")})
run(parityVideoStep{Name: "dossier", Vision: true, Video: true, Args: a("dossier.mp4")})
run(parityVideoStep{Name: "trop lourde", Vision: true, Video: true, Args: a("lourd.mp4")})
run(parityVideoStep{Name: "le moteur ne voit pas", Vision: true, Video: true, Args: a("clip.mp4")})
run(parityVideoStep{Name: "vide", Vision: true, Sees: true, Video: true, Args: a("vide.mp4")})
for _, f := range []string{"clip.mp4", "CLIP.MOV", "anim.webm", "film.mkv", "vieux.avi", "sous/court.mp4"} {
run(parityVideoStep{Name: "voir " + f, Vision: true, Sees: true, Video: true, Args: a(f)})
}
run(parityVideoStep{Name: "chemin absolu", Vision: true, Sees: true, Video: true, Args: a("{{FICHIERS}}/clip.mp4")})
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(map[string]any{"files": raw, "steps": steps}); err != nil {
t.Fatal(err)
}
got := buf.Bytes()
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityVideoFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityVideoFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityVideoFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if !bytes.Equal(got, want) {
t.Errorf("see_video ne produit plus le journal rangé dans %s.\n"+
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityVideoFile)
}
}
// parityVideoFacts : le type et le fichier d'origine de la vidéo relayée (à
// l'octet : une vidéo part telle quelle).
func parityVideoFacts(t *testing.T, part map[string]any, raw map[string][]byte) map[string]any {
u := partVideoURL(part)
i := strings.Index(u, ";base64,")
if i < 0 {
t.Fatalf("partie vidéo sans base64 : %.60q", u)
}
b, err := base64.StdEncoding.DecodeString(u[i+8:])
if err != nil {
t.Fatal(err)
}
out := map[string]any{"mime": strings.TrimPrefix(u[:i], "data:")}
for name, r := range raw {
if bytes.Equal(r, b) {
out["same_as"] = name
}
}
return out
}
+4
View File
@@ -222,6 +222,10 @@ func cacheBust(bucket string) {
cacheMu.Lock()
delete(caches, bucket)
cacheMu.Unlock()
if bucket == bkState {
webKeyCacheBust()
retryStateBust()
}
}
// dbStamp renvoie la date et la taille du fichier de base — de quoi repérer
+10 -1
View File
@@ -340,15 +340,24 @@ func TestRetryScanStartWindows(t *testing.T) {
// Surcharge retry_patterns : ses motifs ne sont pas bornés, donc pas de fenêtre.
func TestTextualToolCallFromCustomFullScan(t *testing.T) {
retryPatterns()
retryMu.Lock()
saved := retryRes
savedCustom := retryCustom
defer func() { retryRes, retryCustom = saved, savedCustom }()
retryRes, retryCustom = []*regexp.Regexp{regexp.MustCompile(`(?mi)\Aoups`)}, true
retryChecked = time.Now() // pas de relecture pendant le test
retryMu.Unlock()
defer func() {
retryMu.Lock()
retryRes, retryCustom = saved, savedCustom
retryMu.Unlock()
}()
s := "oups.\n" + strings.Repeat("Une phrase, puis une autre.\n", 400)
if !textualToolCallFrom(s, len(s)-10) {
t.Fatal("motif ancré en tête manqué")
}
retryMu.Lock()
retryCustom = false
retryMu.Unlock()
if textualToolCallFrom(s, len(s)-10) {
t.Fatal("la fenêtre aurait dû le manquer (le test ne prouve rien sinon)")
}
+58 -2
View File
@@ -10,6 +10,8 @@ import (
"net/url"
"os"
"strings"
"sync"
"time"
)
// web_auth.go protège l'API de pilotage (loki web) quand elle est exposée sur
@@ -19,7 +21,8 @@ import (
// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
// protège llama-server / les complétions). On veut pouvoir donner à un client
// un accès aux complétions sans lui donner le droit de redémarrer la machine,
// et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un
// et inversement. Elle est relue à chaque requête,
// depuis un cache invalidé à chaque écriture (voir readWebKeyErr), pour qu'un
// changement de clé prenne effet sans redémarrer le serveur web.
// readWebKeyErr renvoie la clé de pilotage en distinguant « aucune clé » d'une
@@ -27,9 +30,62 @@ import (
// ouverte. Confondre les deux, c'est ouvrir l'API parce que la base était
// momentanément verrouillée par une commande CLI — une panne d'E/S qui désarme
// l'authentification. requireWebAuth refuse donc plutôt que d'ouvrir.
//
// requireWebAuth la consulte à CHAQUE requête : sans cache, chacune payait une
// ouverture complète de la base sous dbMu (vignettes chargées deux par deux,
// SSE, statut…), en concurrence avec l'écrivain des discussions (AJEAN #120).
// On garde la dernière lecture RÉUSSIE, invalidée comme cachedKV : écriture de
// ce process (cacheBust du bucket d'état), date/taille du fichier pour les
// autres process (la CLI), une seconde au plus. Une lecture ratée n'est jamais
// mise en cache : elle remonte, et l'API reste fermée.
func readWebKeyErr() (string, error) {
path := dbPath()
mtime, size := dbStamp()
c := &webKeyCache
c.Lock()
if c.ok && c.path == path && c.size == size && c.mtime.Equal(mtime) && time.Since(c.when) < cacheMaxAge {
k := c.key
c.Unlock()
return k, nil
}
gen := c.gen
c.Unlock()
b, err := getBytesErr(bkState, "web_key")
return string(b), err
if err != nil {
return "", err
}
// Date et taille relevées APRÈS la lecture : ouvrir la base peut à lui seul
// toucher le fichier (Windows), et un relevé d'avant ne correspondait alors
// jamais. Une écriture externe glissée entre la lecture et le relevé reste
// couverte par l'âge maximal.
mtime, size = dbStamp()
c.Lock()
// Une invalidation pendant la lecture : la valeur lue est peut-être déjà
// l'ancienne — ne pas la garder.
if c.gen == gen {
c.ok, c.path, c.key, c.when, c.mtime, c.size = true, path, string(b), time.Now(), mtime, size
}
c.Unlock()
return string(b), nil
}
var webKeyCache struct {
sync.Mutex
ok bool
gen uint64 // incrémenté à chaque invalidation
path string // la base lue (LOKI_HOME change d'un test à l'autre)
key string
when time.Time
mtime time.Time
size int64
}
// webKeyCacheBust : appelé par cacheBust pour toute écriture du bucket d'état.
func webKeyCacheBust() {
webKeyCache.Lock()
webKeyCache.ok = false
webKeyCache.gen++
webKeyCache.Unlock()
}
// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie (ou
+88
View File
@@ -0,0 +1,88 @@
package loki
import (
"testing"
"time"
bolt "go.etcd.io/bbolt"
)
// Une écriture de ce process est vue tout de suite : le cache ne retarde pas un
// changement de clé.
func TestWebKeyCacheInvalidatedByLocalWrite(t *testing.T) {
testHome(t)
if err := putStr(bkState, "web_key", "ancienne"); err != nil {
t.Fatal(err)
}
if k, err := readWebKeyErr(); err != nil || k != "ancienne" {
t.Fatalf("clé = %q, %v", k, err)
}
if err := putStr(bkState, "web_key", "nouvelle"); err != nil {
t.Fatal(err)
}
if k, err := readWebKeyErr(); err != nil || k != "nouvelle" {
t.Fatalf("clé après écriture = %q, %v ; attendu la nouvelle", k, err)
}
}
// Une écriture d'un AUTRE process (sans cacheBust ici) finit vue : au plus
// cacheMaxAge plus tard.
func TestWebKeyCacheSeesExternalWrite(t *testing.T) {
testHome(t)
if err := putStr(bkState, "web_key", "a"); err != nil {
t.Fatal(err)
}
if k, _ := readWebKeyErr(); k != "a" {
t.Fatalf("clé = %q", k)
}
// Écriture directe, comme le ferait la CLI : pas de cacheBust.
err := withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(bkState)).Put([]byte("web_key"), []byte("b"))
})
})
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(cacheMaxAge + 500*time.Millisecond)
for {
if k, _ := readWebKeyErr(); k == "b" {
return
}
if time.Now().After(deadline) {
t.Fatal("écriture externe jamais vue")
}
time.Sleep(50 * time.Millisecond)
}
}
// Sans changement, les lectures suivantes n'ouvrent plus la base.
func TestWebKeyCacheServesRepeatedReads(t *testing.T) {
testHome(t)
if err := putStr(bkState, "web_key", "k"); err != nil {
t.Fatal(err)
}
if k, _ := readWebKeyErr(); k != "k" {
t.Fatalf("clé = %q", k)
}
// Base verrouillée par « un autre process » : une lecture qui l'ouvrirait
// attendrait la borne de 5 s puis échouerait.
dbMu.Lock()
type res struct {
k string
err error
}
ch := make(chan res, 1)
go func() { k, err := readWebKeyErr(); ch <- res{k, err} }()
select {
case r := <-ch:
dbMu.Unlock()
if r.err != nil || r.k != "k" {
t.Fatalf("clé = %q, %v", r.k, r.err)
}
case <-time.After(300 * time.Millisecond):
dbMu.Unlock()
<-ch
t.Fatal("lecture servie par la base, pas par le cache")
}
}
+4
View File
@@ -84,5 +84,9 @@ func toolStepExtras(name, label string, res toolExecResult) (image, shot string,
m := seeImageMessage(label, res.VisionImg)
relay = &m
}
if res.VisionVideo != nil {
m := seeVideoMessage(label, res.VisionVideo)
relay = &m
}
return image, shot, relay
}
+42 -19
View File
@@ -152,10 +152,19 @@ func attachNote(files []attachInfo) string {
head = "Fichiers joints à ce message, déposés dans ton dossier de travail :"
}
var lines []string
video := false
for _, f := range files {
lines = append(lines, fmt.Sprintf("- %s (%s)", f.Path, humanBytes(f.Size)))
video = video || videoMime(f.Name) != ""
}
return head + "\n" + strings.Join(lines, "\n") + "\n\n"
note := head + "\n" + strings.Join(lines, "\n") + "\n"
// Vidéo jointe que le moteur ne sait pas lire (chat_video.go) : dire comment
// la regarder quand même, au lieu de laisser le modèle tâtonner — Strata et
// les API externes ignoraient la vidéo en silence.
if video && visionEnabled() && !videoInputSupported() {
note += videoFallbackNote
}
return note + "\n"
}
// imageMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme image (contenu
@@ -189,9 +198,11 @@ func visionEnabled() bool {
// userMessageContent construit le champ Content du message utilisateur. Cas
// courant : une simple chaîne (note des fichiers joints + texte). Quand la vision
// est active ET qu'au moins une pièce jointe est une image, on renvoie le format
// multimodal d'OpenAI — une partie `text` suivie d'une partie `image_url` par
// image (data URI base64) — que llama-server comprend une fois --mmproj chargé :
// est active ET qu'au moins une pièce jointe est une image (ou une vidéo que le
// moteur sait lire), on renvoie le format multimodal d'OpenAI — une partie
// `text` suivie d'une partie `image_url` par image (data URI base64) et d'une
// partie `input_video` par vidéo — que llama-server comprend une fois --mmproj
// chargé :
// le modèle VOIT alors l'image au lieu de devoir l'ouvrir comme un fichier binaire.
// Les images ainsi intégrées sortent de la note textuelle (inutile de dire au
// modèle d'aller ouvrir un fichier qu'il a déjà sous les yeux) ; les autres
@@ -209,31 +220,43 @@ func userMessageContent(files []attachInfo, prompt string) any {
if err != nil {
return attachNote(files) + prompt
}
var imgParts []map[string]any
var imgParts, vidParts []map[string]any
var textFiles []attachInfo
for _, f := range files {
mime := imageMime(f.Name)
if mime == "" {
textFiles = append(textFiles, f)
if mime := imageMime(f.Name); mime != "" {
b, err := os.ReadFile(filepath.Join(dir, f.Name))
if err != nil {
textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier
continue
}
// Photo prise au téléphone : l'orientation vit dans un tag EXIF que le
// projecteur ignore (il voit les pixels bruts, donc l'image couchée), et
// une image de plusieurs milliers de pixels ne fait que gonfler le base64
// — voir prepareImageForModel (web_upload_orient.go).
b, mime = prepareImageForModel(b, mime)
imgParts = append(imgParts, imageURLPart(b, mime))
continue
}
b, err := os.ReadFile(filepath.Join(dir, f.Name))
if err != nil {
textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier
// Vidéo : seulement si le moteur sait la lire (chat_video.go) ; sinon
// elle reste un fichier, annoncé avec la marche à suivre (attachNote).
// Pas de redimensionnement : le moteur échantillonne les images lui-même.
if mime := videoMime(f.Name); mime != "" && videoInputSupported() {
b, err := os.ReadFile(filepath.Join(dir, f.Name))
if err != nil || len(b) == 0 || len(b) > maxVideoBytes {
textFiles = append(textFiles, f)
continue
}
vidParts = append(vidParts, videoPart(b, mime))
continue
}
// Photo prise au téléphone : l'orientation vit dans un tag EXIF que le
// projecteur ignore (il voit les pixels bruts, donc l'image couchée), et
// une image de plusieurs milliers de pixels ne fait que gonfler le base64
// — voir prepareImageForModel (web_upload_orient.go).
b, mime = prepareImageForModel(b, mime)
imgParts = append(imgParts, imageURLPart(b, mime))
textFiles = append(textFiles, f)
}
if len(imgParts) == 0 {
if len(imgParts) == 0 && len(vidParts) == 0 {
return attachNote(files) + prompt
}
parts := []map[string]any{{"type": "text", "text": attachNote(textFiles) + prompt}}
return append(parts, imgParts...)
parts = append(parts, imgParts...)
return append(parts, vidParts...)
}
// workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et,
+4
View File
@@ -58,6 +58,9 @@ pub const COMPACT_SUMMARY_PREFIX: &str = "[CONTEXT COMPACTED]";
/// Remplace la partie `image_url` d'un message multimodal dans [`msg_text`].
pub const IMAGE_LOST_MARKER: &str = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]";
/// `videoLostMarker` : remplace la partie `input_video` (chat_video.go).
pub const VIDEO_LOST_MARKER: &str = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]";
/// La réponse assistant synthétique qui suit le résumé.
pub const COMPACT_ACK_MSG: &str = "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.";
@@ -265,6 +268,7 @@ pub fn msg_text(m: &Message) -> String {
}
}
Some("image_url") => b.push_str(IMAGE_LOST_MARKER),
Some("input_video") => b.push_str(VIDEO_LOST_MARKER),
_ => {}
}
}
+101 -16
View File
@@ -14,8 +14,9 @@
//! - compaction par [`Compactor`] : résumé demandé au moteur
//! ([`EngineSummarizer`]), blocs archivés pour recall par le Go
//! ([`StoreRecall`]) ;
//! - images jointes (`loki-img:…`) lues sur disque et ouvertes par le Go
//! (déchiffrées), gardées en cache comme `chatImageDataURL` ;
//! - images et vidéos jointes (`loki-img:…`, `loki-vid:…`) lues sur disque
//! et ouvertes par le Go (déchiffrées), gardées en cache comme
//! `chatImageDataURL` / `chatVideoDataURL` ;
//! - slots du moteur (lot 31) : avant chaque requête au moteur local, le Go
//! rend le slot où l'envoyer (SIDE_SLOT) et recharge au besoin l'état
//! gardé de la discussion (SLOT_PERSIST) ; la requête est comptée en vol,
@@ -46,8 +47,8 @@ use super::stream::{CacheHintFn, PreflightFn, RetryPatterns};
use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost};
use super::types::{AskEvent, DiffLine, Message, Tool};
use super::wire::{
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME,
build_chat_payload, content_parts, part_image_url, wire_messages,
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME, VID_REF_SCHEME,
build_chat_payload, content_parts, part_image_url, part_video_url, wire_messages,
};
use crate::store::{BK_STATE, Store};
@@ -97,10 +98,32 @@ fn image_mime(name: &str) -> &'static str {
/// Nom d'une image rangée, si la référence est sûre (ni chemin ni `..`).
fn image_name(r: &str) -> Option<&str> {
let name = r.strip_prefix(IMG_REF_SCHEME)?;
safe_media_name(r.strip_prefix(IMG_REF_SCHEME)?)
}
fn safe_media_name(name: &str) -> Option<&str> {
(!name.is_empty() && !name.contains(['/', '\\']) && !name.contains("..")).then_some(name)
}
/// Média rangé : (clé du cache, fichier relatif à `LOKI_HOME`, type MIME).
/// Une image a pour clé son nom (dossier `chatimg`), une vidéo jointe
/// (`loki-vid:`, chat_video.go) sa référence entière (dossier `chatvid`).
fn media_ref(r: &str) -> Option<(String, PathBuf, &'static str)> {
if let Some(name) = image_name(r) {
return Some((
name.to_string(),
Path::new("chatimg").join(name),
image_mime(name),
));
}
let name = safe_media_name(r.strip_prefix(VID_REF_SCHEME)?)?;
let mime = match crate::imagetools::video_mime(name) {
"" => "video/mp4",
m => m,
};
Some((r.to_string(), Path::new("chatvid").join(name), mime))
}
#[derive(Default)]
struct ImgCacheInner {
map: HashMap<String, String>,
@@ -129,9 +152,10 @@ impl ImageCache {
}
}
/// Data-URL d'une image (`loki-img:`) ou d'une vidéo (`loki-vid:`) rangée.
pub fn get(&self, r: &str) -> Option<String> {
let name = image_name(r)?;
self.inner.lock().ok()?.map.get(name).cloned()
let (key, _, _) = media_ref(r)?;
self.inner.lock().ok()?.map.get(&key).cloned()
}
fn put(&self, name: &str, url: String) {
@@ -151,27 +175,30 @@ impl ImageCache {
}
}
/// Lit et ouvre (par le Go) les images référencées pas encore en cache.
/// Une image illisible reste absente : la requête dira « indisponible ».
/// Lit et ouvre (par le Go) les images et vidéos référencées pas encore
/// en cache. Un média illisible reste absent : la requête dira
/// « indisponible ».
pub async fn prefetch(&self, store: &Store, msgs: &[Message]) {
let home = self.home();
for m in msgs {
for p in content_parts(m.content.as_ref()) {
let Some(name) = image_name(part_image_url(p)) else {
let u = match part_image_url(p) {
"" => part_video_url(p),
u => u,
};
let Some((key, rel, mime)) = media_ref(u) else {
continue;
};
if self.inner.lock().is_ok_and(|g| g.map.contains_key(name)) {
if self.inner.lock().is_ok_and(|g| g.map.contains_key(&key)) {
continue;
}
let Ok(raw) = tokio::fs::read(self.dir.join(name)).await else {
let Ok(raw) = tokio::fs::read(home.join(rel)).await else {
continue;
};
let Ok(b) = store.open(raw).await else {
continue;
};
self.put(
name,
format!("data:{};base64,{}", image_mime(name), b64(&b)),
);
self.put(&key, format!("data:{mime};base64,{}", b64(&b)));
}
}
}
@@ -586,6 +613,11 @@ pub fn tool_outcome(v: &Value, label: &str) -> ToolOutcome {
{
relay = Some(see_image_message(label, img.clone()));
}
if relay.is_none()
&& let Some(vid) = v.get("vision_video").filter(|i| i.is_object())
{
relay = Some(see_video_message(label, vid.clone()));
}
ToolOutcome {
result: s("result"),
diff: v
@@ -617,6 +649,20 @@ pub fn see_image_message(label: &str, img: Value) -> Message {
}
}
/// `seeVideoMessage` : la vidéo relayée au modèle.
pub fn see_video_message(label: &str, vid: Value) -> Message {
let text = if label.is_empty() {
"Vidéo :".to_string()
} else {
format!("Vidéo demandée ({label}) :")
};
Message {
role: "user".into(),
content: Some(json!([{"type": "text", "text": text}, vid])),
..Default::default()
}
}
impl GoTurnHost {
/// Les outils exécutés par la façade, sauf `subagent` ; le reste par le
/// Go (`/tool/exec`). Aussi celui des tours d'un sous-agent.
@@ -751,6 +797,45 @@ impl GoTurnHost {
};
}
}
// see_video (chat_video_tool.go) : comme see_image, dans le dossier de
// la discussion ; la vidéo relayée au modèle, toujours éphémère.
if name == "see_video" {
let cfg = self.shared.store.config().await;
let vision = crate::system::vision_enabled(&cfg);
let st = crate::tools::GoToolState {
store: self.shared.store.clone(),
caps: self.caps.clone(),
};
match crate::tools::ToolState::context(&st).await {
Ok(ctx) => {
let home = self.shared.images.home();
let vcfg = cfg.clone();
let video_ok = tokio::task::spawn_blocking(move || {
crate::imagetools::video_input_supported(&vcfg, &home)
})
.await
.unwrap_or(false);
let sees = self.shared.vision.sees(&self.shared.store, &cfg);
let (result, vid) = crate::imagetools::see_video(
label,
&ctx.cwd,
&ctx.target,
vision,
video_ok,
sees,
)
.await;
return ToolOutcome {
result,
relay: vid.map(|v| see_video_message(label, v)),
..Default::default()
};
}
Err(e) => {
eprintln!("[outils] contexte indisponible, see_video par le Go : {e}")
}
}
}
// Navigateur piloté (lot 44) : la session est ici ; aperçu de la page
// après une action qui la change (jamais après une erreur), capture
// relayée au modèle.
+6 -3
View File
@@ -141,10 +141,13 @@ pub fn steer_system(msgs: &[Message], hint: &str) -> Vec<Message> {
}
/// `dedupableTool` : un appel identique se court-circuite, sauf bash et ses
/// jobs (effets de bord), see_image et le navigateur (état vivant, image à
/// part).
/// jobs (effets de bord), see_image, see_video et le navigateur (état
/// vivant, image ou vidéo à part).
pub fn dedupable_tool(name: &str) -> bool {
!matches!(name, "bash" | "bash_bg" | "bash_tail" | "see_image") && !name.starts_with("browser_")
!matches!(
name,
"bash" | "bash_bg" | "bash_tail" | "see_image" | "see_video"
) && !name.starts_with("browser_")
}
/// `repeatedCallResult` : réponse à un appel RIGOUREUSEMENT identique déjà
+22 -7
View File
@@ -49,7 +49,7 @@ use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value;
use tokio::sync::watch;
use super::compact::{IMAGE_LOST_MARKER, is_loki_injected, without_ctx_update};
use super::compact::{IMAGE_LOST_MARKER, VIDEO_LOST_MARKER, is_loki_injected, without_ctx_update};
use super::journal::compact_log;
use super::types::{LogEvent, Message};
use crate::store::{CryptError, Store};
@@ -269,24 +269,39 @@ fn decode_list<T: DeserializeOwned>(v: Value) -> Vec<T> {
/// `stripImageParts` : un message multimodal persisté (base64 d'une capture
/// d'avant les captures éphémères) est aplati en son texte suivi de
/// [`IMAGE_LOST_MARKER`] — rejoué à chaque tour, il dépassait le contexte.
/// Une vidéo jointe (`input_video`) a le même sort, marquée par
/// [`VIDEO_LOST_MARKER`].
pub fn strip_image_parts(msgs: &mut [Message]) {
for m in msgs.iter_mut() {
let Some(Value::Array(parts)) = &m.content else {
continue;
};
let mut texts: Vec<&str> = Vec::new();
let mut dropped = false;
let (mut dropped, mut dropped_vid) = (false, false);
for p in parts.iter().filter_map(Value::as_object) {
if p.get("type").and_then(Value::as_str) == Some("image_url") {
dropped = true;
continue;
match p.get("type").and_then(Value::as_str) {
Some("image_url") => {
dropped = true;
continue;
}
Some("input_video") => {
dropped_vid = true;
continue;
}
_ => {}
}
if let Some(Value::String(t)) = p.get("text") {
texts.push(t);
}
}
if dropped {
let text = texts.join("\n") + IMAGE_LOST_MARKER;
if dropped || dropped_vid {
let mut text = texts.join("\n");
if dropped {
text += IMAGE_LOST_MARKER;
}
if dropped_vid {
text += VIDEO_LOST_MARKER;
}
m.content = Some(Value::String(text));
}
}
+21 -4
View File
@@ -621,7 +621,7 @@ pub fn tool_call_label(name: &str, args: &Map<String, Value>) -> String {
match name {
"mem_search" | "web_search" | "web_images" | "recall_search" => arg_str(args, "query"),
"mem_read" | "mem_add" | "mem_edit" | "edit" | "write" | "read" | "git_diff"
| "see_image" => arg_str(args, "file"),
| "see_image" | "see_video" => arg_str(args, "file"),
"recall" | "bash_tail" => arg_str(args, "id"),
"tracker" => (go_str(args.get("action")) + " " + &go_str(args.get("name")))
.trim()
@@ -922,11 +922,19 @@ impl RetryPatterns {
if over.is_empty() {
return None;
}
let pats = over
// Un motif vide (ou blanc) est écarté : `(?mi)` correspond à TOUT, et
// chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
// motif utilisable, les motifs embarqués, comme le Go
// (compileRetryPatterns).
let pats: Vec<Box<dyn RetryPattern>> = over
.iter()
.filter(|p| !p.trim().is_empty())
.filter_map(|p| compile_go_regex(&format!("(?mi){p}")))
.map(|r| Box::new(GoRegex(r)) as Box<dyn RetryPattern>)
.collect();
if pats.is_empty() {
return None;
}
Some(RetryPatterns::custom(pats))
}
@@ -2907,10 +2915,19 @@ mod tests {
/// en `(?mi)`, sémantique du Go (classes et limites de mot ASCII).
#[test]
fn surcharge_retry_patterns() {
for raw in ["", "null", "[]", "[\"a\", 1]", "\"x\"", "[\"a\"] x"] {
for raw in [
"",
"null",
"[]",
"[\"a\", 1]",
"\"x\"",
"[\"a\"] x",
"[\"\"]",
"[\" \", \"(\"]",
] {
assert!(RetryPatterns::from_override(raw).is_none(), "{raw}");
}
let p = RetryPatterns::from_override(r#"["^oups", "[", "\\bdo_\\w+\\("]"#).unwrap();
let p = RetryPatterns::from_override(r#"["^oups", "[", "", "\\bdo_\\w+\\("]"#).unwrap();
assert!(p.is_custom());
assert_eq!(p.len(), 2, "la regex illisible est sautée");
assert!(p.textual_tool_call("Bonjour\nOUPS !"));
+11 -1
View File
@@ -384,6 +384,13 @@ pub fn side_slot_pool_error(msg: &str) -> bool {
&& !REQ_EXCEEDS.is_match(msg)
}
/// Le relais porte-t-il une vidéo (see_video, partie `input_video`) ?
fn is_video_relay(m: &Message) -> bool {
super::wire::content_parts(m.content.as_ref())
.iter()
.any(|p| p.get("type").and_then(Value::as_str) == Some("input_video"))
}
fn text_msg(role: &str, text: impl Into<String>) -> Message {
Message {
role: role.into(),
@@ -1071,8 +1078,11 @@ async fn run_turn_loop<H: TurnHost>(
push_result(&mut messages, &mut extra, cap_tool_result(&ex.result));
if let Some(img) = ex.relay {
match kt.as_mut() {
// Éphémère : jamais dans l'historique.
// Éphémère : jamais dans l'historique. Une vidéo
// (see_video) l'est toujours, KEEP_TURN_IMAGES compris :
// le rangement ne sait garder que des images.
None => messages.push(img),
Some(_) if is_video_relay(&img) => messages.push(img),
// KEEP_TURN_IMAGES : gardée par référence, à l'essai
// jusqu'à la mesure de son coût.
Some(k) => {
+89 -14
View File
@@ -60,6 +60,10 @@ pub const TOOLS_OFF_HINT: &str = "Do not call any more tools. Answer now, direct
/// `imgRefScheme` : adresse d'une image rangée sur disque (chat_images.go).
pub const IMG_REF_SCHEME: &str = "loki-img:";
/// `vidRefScheme` : adresse d'une vidéo jointe rangée sur disque
/// (chat_video.go).
pub const VID_REF_SCHEME: &str = "loki-vid:";
/// Préfixes des messages système propres au projet ou à la conversation
/// (`isProjectSystem`).
pub const PROJECT_CONTEXT_PREFIX: &str = "Project context";
@@ -308,35 +312,61 @@ pub fn part_image_url(p: &Map<String, Value>) -> &str {
.unwrap_or("")
}
/// `expandImageRefs` : COPIE des messages prête pour le modèle — chaque
/// référence `loki-img:` redevient sa data-URL (`image_data_url`, qui tient
/// lieu de `chatImageDataURL` : disque, cache, déchiffrement). Les messages
/// sans référence sont repris tels quels ; aucune référence : pas de copie.
/// Une image introuvable devient une mention textuelle.
/// `partVideoURL` : l'adresse d'une partie `input_video` (`url`, sinon
/// `data`), "" pour toute autre partie (chat_video.go).
pub fn part_video_url(p: &Map<String, Value>) -> &str {
if p.get("type").and_then(Value::as_str) != Some("input_video") {
return "";
}
let Some(iv) = p.get("input_video").and_then(Value::as_object) else {
return "";
};
match iv.get("url").and_then(Value::as_str).unwrap_or("") {
"" => iv.get("data").and_then(Value::as_str).unwrap_or(""),
u => u,
}
}
/// `expandVideoRefs(expandImageRefs(…))` : COPIE des messages prête pour le
/// modèle — chaque référence `loki-img:` ou `loki-vid:` redevient sa
/// data-URL (`image_data_url`, qui tient lieu de `chatImageDataURL` et
/// `chatVideoDataURL` : disque, cache, déchiffrement). Les messages sans
/// référence sont repris tels quels ; aucune référence : pas de copie. Un
/// média introuvable devient une mention textuelle.
pub fn expand_image_refs<'a>(
msgs: &'a [Message],
image_data_url: &dyn Fn(&str) -> Option<String>,
) -> Cow<'a, [Message]> {
let is_ref = |p: &Map<String, Value>| {
part_image_url(p).starts_with(IMG_REF_SCHEME)
|| part_video_url(p).starts_with(VID_REF_SCHEME)
};
let mut out: Option<Vec<Message>> = None;
for (i, m) in msgs.iter().enumerate() {
let parts = content_parts(m.content.as_ref());
if !parts
.iter()
.any(|p| part_image_url(p).starts_with(IMG_REF_SCHEME))
{
if !parts.iter().any(|p| is_ref(p)) {
continue;
}
let mut np: Vec<Value> = Vec::with_capacity(parts.len());
for p in parts {
let u = part_image_url(p);
if !u.starts_with(IMG_REF_SCHEME) {
if !is_ref(p) {
np.push(Value::Object(p.clone()));
continue;
}
match image_data_url(u) {
Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})),
let u = part_image_url(p);
if u.starts_with(IMG_REF_SCHEME) {
match image_data_url(u) {
Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})),
None => np.push(
json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}),
),
}
continue;
}
match image_data_url(part_video_url(p)) {
Some(d) => np.push(json!({"type": "input_video", "input_video": {"url": d}})),
None => np.push(
json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}),
json!({"type": "text", "text": "[vidéo indisponible : fichier supprimé]"}),
),
}
}
@@ -1807,6 +1837,51 @@ mod tests {
));
}
// --- chat_video_test.go (partie envoi) ----------------------------------
#[test]
fn video_refs_round_trip() {
let url = "data:video/mp4;base64,AAAAGGZ0eXA=";
let reference = format!("{VID_REF_SCHEME}0123abcd.mp4");
let img_ref = format!("{IMG_REF_SCHEME}ab.png");
let set = vec![Message {
role: "user".into(),
content: Some(json!([
{"type": "text", "text": "regarde"},
{"type": "image_url", "image_url": {"url": img_ref}},
{"type": "input_video", "input_video": {"url": reference}},
{"type": "input_video", "input_video": {"data": "data:video/webm;base64,AA=="}}
])),
..Default::default()
}];
let resolver = |r: &str| {
if r == reference {
Some(url.to_string())
} else if r == img_ref {
Some("data:image/png;base64,AA==".to_string())
} else {
None
}
};
let out = expand_image_refs(&set, &resolver);
let parts = content_parts(out[0].content.as_ref());
assert_eq!(part_image_url(parts[1]), "data:image/png;base64,AA==");
assert_eq!(part_video_url(parts[2]), url);
assert_eq!(parts[2]["type"], "input_video");
// Vidéo déjà en ligne (champ data) : reprise telle quelle.
assert_eq!(part_video_url(parts[3]), "data:video/webm;base64,AA==");
let first = content_parts(set[0].content.as_ref());
assert!(
part_video_url(first[2]).starts_with(VID_REF_SCHEME),
"l'historique n'est pas touché"
);
let out = expand_image_refs(&set, &|_: &str| None);
assert_eq!(
content_parts(out[0].content.as_ref())[2]["text"],
json!("[vidéo indisponible : fichier supprimé]")
);
}
// --- chat_compact_cont_test.go ------------------------------------------
#[test]
+14
View File
@@ -194,6 +194,20 @@ pub fn strip_inline_images(msgs: Vec<Message>) -> Vec<Message> {
let e = elide_data_uri(url);
iu.insert("url".into(), Value::String(e));
}
// Vidéo jointe (input_video, chat_video.go) : même
// élision, sur ses deux champs.
if let Value::Object(part) = p
&& let Some(Value::Object(iv)) = part.get_mut("input_video")
{
for key in ["url", "data"] {
if let Some(Value::String(u)) = iv.get(key)
&& u.starts_with("data:")
{
let e = elide_data_uri(u);
iv.insert(key.into(), Value::String(e));
}
}
}
}
}
m
+163
View File
@@ -225,6 +225,169 @@ pub async fn see_image(
)
}
// --- see_video (chat_video.go, chat_video_tool.go) ------------------------------
/// `maxVideoBytes` : au-delà, des dizaines de milliers de jetons visuels.
pub const MAX_VIDEO_BYTES: i64 = 200 << 20;
/// `videoFallbackNote` : consigne quand le moteur ne lit pas la vidéo.
pub const VIDEO_FALLBACK_NOTE: &str = "Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.\n";
/// `videoMimes` : les vidéos qu'on sait montrer au modèle.
pub fn video_mime(name: &str) -> &'static str {
let ext = Path::new(name)
.extension()
.map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
.unwrap_or_default();
match ext.as_str() {
".mp4" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
".mkv" => "video/x-matroska",
".avi" => "video/x-msvideo",
_ => "",
}
}
/// `videoPart` : la partie `input_video` que lit llama-server.
pub fn video_part(b: &[u8], mime: &str) -> Value {
json!({
"type": "input_video",
"input_video": { "url": format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(b)) },
})
}
/// `videoInputSupported` : preset local avec projecteur (MMPROJ) et moteur
/// dont l'aide annonce `--video-fps`. Lit le disque et lance le moteur :
/// à appeler hors du runtime (`spawn_blocking`). `home` : `LOKI_HOME`.
pub fn video_input_supported(cfg: &crate::serve::Cfg, home: &Path) -> bool {
use crate::serve::{get, trim};
if crate::serve::is_external(cfg)
|| crate::serve::is_strata(cfg)
|| trim(get(cfg, "MMPROJ")).is_empty()
{
return false;
}
let mut bin = trim(get(cfg, "BIN")).to_string();
if bin.is_empty() {
return false;
}
if !crate::paths::go_is_abs(&bin) {
bin = home.join(&bin).to_string_lossy().into_owned();
}
let ep = crate::serve::run::EnginePaths {
home: home.to_path_buf(),
};
bin_help_has_video(&ep.prebuilt_resolve_bin(&bin))
}
/// `binHelpHasVideo` : l'aide du binaire, une fois par binaire et par date
/// de modification, chemin des bibliothèques passé à la seule commande.
pub fn bin_help_has_video(bin: &str) -> bool {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
let Ok(st) = std::fs::metadata(bin) else {
return false;
};
if st.is_dir() {
return false;
}
let key = format!("{bin}\0{:?}", st.modified().ok());
let cache = CACHE.get_or_init(Default::default);
if let Some(v) = cache.lock().ok().and_then(|c| c.get(&key).copied()) {
return v;
}
let mut env = crate::serve::run::Env::default();
let dir = Path::new(bin)
.parent()
.map(|d| d.to_string_lossy().into_owned())
.unwrap_or_default();
let (k, v) = crate::serve::run::library_path_value(&env, &dir);
env.set(&k, &v);
let help = crate::serve::run::bin_help(&env, bin);
let ok = help.contains("--video-fps");
if let Ok(mut c) = cache.lock() {
c.insert(key, ok);
}
ok
}
/// `toolSeeVideo` : (accusé, partie vidéo). Même ordre de refus que
/// [`see_image`] ; `video_ok` : [`video_input_supported`], `sees` : la sonde
/// du moteur, appelée en dernier.
pub async fn see_video(
path: &str,
cwd: &Path,
target: &str,
vision: bool,
video_ok: bool,
sees: impl std::future::Future<Output = bool>,
) -> (String, Option<Value>) {
let err = |m: String| (m, None);
if !vision {
return err("[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo".into());
}
if !target.is_empty() {
return err("[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)".into());
}
if path.is_empty() {
return err("[erreur] chemin de fichier manquant".into());
}
if !video_ok {
return err(format!(
"[erreur] {}",
VIDEO_FALLBACK_NOTE.trim_end_matches('\n')
));
}
let abs = crate::tools::policy::resolve_agent_path(cwd, path);
let mime = video_mime(&abs.to_string_lossy());
if mime.is_empty() {
return err(format!(
"[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : {path}"
));
}
let st = match std::fs::metadata(&abs) {
Ok(m) => m,
Err(_) => return err(format!("[erreur] fichier introuvable : {path}")),
};
if st.is_dir() {
return err(format!("[erreur] c'est un dossier, pas une vidéo : {path}"));
}
if st.len() as i64 > MAX_VIDEO_BYTES {
return err(format!(
"[erreur] vidéo trop lourde ({}, max {}) — coupe-la en un clip plus court : {path}",
crate::downloads::human_bytes(st.len() as i64),
crate::downloads::human_bytes(MAX_VIDEO_BYTES)
));
}
if !sees.await {
return err(format!(
"[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir {path}"
));
}
let b = match std::fs::read(&abs) {
Ok(b) => b,
Err(e) => {
return err(format!(
"[erreur] lecture impossible : {}",
crate::paths::os_err("open", &abs.to_string_lossy(), &e)
));
}
};
if b.is_empty() {
return err(format!("[erreur] fichier vide : {path}"));
}
let base = abs
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
(
format!("[ok] vidéo chargée : {base}"),
Some(video_part(&b, mime)),
)
}
// --- web_screenshot -------------------------------------------------------------
/// Le résultat de `web_screenshot` : le texte, et la capture rangée
+102
View File
@@ -25,6 +25,14 @@ pub struct ServerConfig {
pub enabled: bool,
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
pub disabled_tools: Vec<String>,
/// `CallTimeoutSec` : délai d'un appel d'outil de ce serveur, en
/// secondes ; 0 = défaut ([`CALL_TIMEOUT`]).
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
pub call_timeout_sec: i64,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
impl Default for ServerConfig {
@@ -37,6 +45,7 @@ impl Default for ServerConfig {
headers: BTreeMap::new(),
enabled: true,
disabled_tools: Vec::new(),
call_timeout_sec: 0,
}
}
}
@@ -78,6 +87,7 @@ impl<'de> Deserialize<'de> for ServerConfig {
Some(x) => x.as_bool().unwrap_or(false),
},
disabled_tools: list("disabledTools"),
call_timeout_sec: v.get("callTimeoutSec").and_then(Value::as_i64).unwrap_or(0),
})
}
}
@@ -282,6 +292,53 @@ pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
/// `mcpCallTimeout`.
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
/// config.
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du
/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s.
pub fn call_timeout_for(cfg: &ServerConfig, args: &Map<String, Value>) -> Duration {
let mut d = CALL_TIMEOUT;
if cfg.call_timeout_sec > 0 {
d = Duration::from_secs(cfg.call_timeout_sec as u64);
}
if let Some(t) = arg_timeout(args) {
let t = t + Duration::from_secs(30);
if t > d {
d = t;
}
}
d.min(CALL_TIMEOUT_MAX)
}
/// `mcpArgTimeout` : l'argument `timeout` d'un appel. Au-delà de 3600, lu en
/// millisecondes (5000 = 5 s) ; en deçà, en secondes (600 = 10 min).
fn arg_timeout(args: &Map<String, Value>) -> Option<Duration> {
let v = match args.get("timeout")? {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse::<f64>().ok()?,
_ => return None,
};
if !v.is_finite() || v <= 0.0 {
return None;
}
if v > 3600.0 {
// Plafonné bien avant tout débordement (CALL_TIMEOUT_MAX suit).
return Some(Duration::from_millis(v.min(1e12) as u64));
}
Some(Duration::from_secs_f64(v))
}
/// `mcpCall` pour un délai dépassé : le serveur est vivant mais l'outil est
/// lent, pas de nouvel essai (il doublerait l'attente, et l'effet de l'outil).
pub fn call_deadline_error(timeout: Duration) -> String {
let secs = (timeout.as_millis() + 500) / 1000;
format!(
"[erreur MCP] pas de réponse après {} (délai de l'appel ; un argument timeout plus grand l'allonge)",
crate::tools::shell::go_duration(Duration::from_secs(secs as u64))
)
}
/// Nom du lanceur (`filepath.Base`), en minuscules, sans `.exe` puis sans
/// `.cmd`.
@@ -463,6 +520,51 @@ fn resource_contents_json(v: &Value) -> String {
mod tests {
use super::*;
/// Les cas de `TestMCPCallTimeoutFor` côté Go.
#[test]
fn delai_d_appel() {
let cfg = |n: i64| ServerConfig {
call_timeout_sec: n,
..Default::default()
};
let args = |v: Value| {
let mut m = Map::new();
m.insert("timeout".into(), v);
m
};
let s = Duration::from_secs;
let none = Map::new();
assert_eq!(call_timeout_for(&cfg(0), &none), CALL_TIMEOUT);
assert_eq!(call_timeout_for(&cfg(600), &none), s(600));
assert_eq!(call_timeout_for(&cfg(0), &args(json!(300.0))), s(330));
assert_eq!(call_timeout_for(&cfg(0), &args(json!(200000.0))), s(230));
assert_eq!(
call_timeout_for(&cfg(0), &args(json!(5000.0))),
CALL_TIMEOUT
);
assert_eq!(call_timeout_for(&cfg(0), &args(json!("400"))), s(430));
assert_eq!(
call_timeout_for(&cfg(0), &args(json!(86400.0 * 1000.0))),
CALL_TIMEOUT_MAX
);
assert_eq!(call_timeout_for(&cfg(30), &none), s(30));
assert_eq!(
call_deadline_error(s(330)),
"[erreur MCP] pas de réponse après 5m30s (délai de l'appel ; un argument timeout plus grand l'allonge)"
);
// Le champ survit à un aller-retour JSON, absent quand il vaut 0.
let c: ServerConfig =
serde_json::from_value(json!({"command": "x", "callTimeoutSec": 45})).unwrap();
assert_eq!(c.call_timeout_sec, 45);
assert_eq!(serde_json::to_value(&c).unwrap()["callTimeoutSec"], 45);
assert!(
serde_json::to_value(cfg(0))
.unwrap()
.get("callTimeoutSec")
.is_none()
);
}
#[test]
fn noms() {
assert_eq!(sanitize("mon serveur!"), "mon_serveur");
+15 -5
View File
@@ -39,6 +39,9 @@ pub const INTERNAL_TOOLS: &str = "/internal/facade/v1/mcp/tools";
pub const INTERNAL_CALL: &str = "/internal/facade/v1/mcp/call";
pub const INTERNAL_PREWARM: &str = "/internal/facade/v1/mcp/prewarm";
/// Erreur d'un appel au délai dépassé (`context.DeadlineExceeded` du Go).
const CALL_DEADLINE: &str = "context deadline exceeded";
/// Issue d'une tentative de connexion.
#[derive(Default)]
struct SlotState {
@@ -251,15 +254,20 @@ impl Mcp {
return format!("[erreur] outil MCP inconnu ou serveur non connecté: {name}");
};
let cap = config::output_cap(config::ctx_window(&store.config().await));
let mut err = match self.call_once(&server, &tool, args, cap).await {
let servers = Self::load(store).await;
let timeout =
config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args);
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => e,
};
let servers = Self::load(store).await;
if err == CALL_DEADLINE {
return config::call_deadline_error(timeout);
}
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
self.invalidate(&server).await;
self.ensure(&server, cfg, version).await;
match self.call_once(&server, &tool, args, cap).await {
match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => err = e,
}
@@ -273,6 +281,7 @@ impl Mcp {
tool: &str,
args: &Map<String, Value>,
cap: usize,
timeout: std::time::Duration,
) -> Result<String, String> {
let slot = lock(&self.slots).get(server).cloned();
let sess = slot
@@ -281,9 +290,9 @@ impl Mcp {
.and_then(|st| st.sess.clone())
.filter(|s| !s.is_closed())
.ok_or_else(|| format!("serveur {server} non connecté"))?;
let res = tokio::time::timeout(config::CALL_TIMEOUT, sess.call_tool(tool, args))
let res = tokio::time::timeout(timeout, sess.call_tool(tool, args))
.await
.map_err(|_| "context deadline exceeded".to_string())??;
.map_err(|_| CALL_DEADLINE.to_string())??;
let out = config::cap_output(config::flatten_content(&res), cap);
if res.get("isError").and_then(Value::as_bool) == Some(true) {
return Ok(format!("[l'outil a renvoyé une erreur]\n{out}"));
@@ -570,6 +579,7 @@ pub async fn serve(
.collect(),
enabled: d.opt_bool("enabled").unwrap_or(true),
disabled_tools: Vec::new(),
call_timeout_sec: 0,
};
let name = d.str("name");
let res = mcp
+245
View File
@@ -0,0 +1,245 @@
{
"files": {
"CLIP.MOV": "AAAAFGZ0eXBxdCAgIG1vdg==",
"anim.webm": "GkXfoyB3ZWJt",
"clip.mp4": "AAAAGGZ0eXBtcDQyIGZhdXNzZSB2aWTDqW8=",
"film.mkv": "GkXfoyBtYXRyb3NrYQ==",
"sous/court.mp4": "ZnR5cCBzb3VzLWRvc3NpZXI=",
"texte.txt": "cGFzIHVuZSB2aWTDqW8=",
"vide.mp4": "",
"vieux.avi": "UklGRhAAAABBVkkgTElTVA=="
},
"steps": [
{
"name": "sans vision",
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo",
"probes": 0
},
{
"name": "chemin manquant",
"vision": true,
"video": true,
"args": {
"file": ""
},
"label": "",
"result": "[erreur] chemin de fichier manquant",
"probes": 0
},
{
"name": "sans argument",
"vision": true,
"video": true,
"args": {},
"label": "",
"result": "[erreur] chemin de fichier manquant",
"probes": 0
},
{
"name": "moteur sans vidéo",
"vision": true,
"sees": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.",
"probes": 0
},
{
"name": "pas une vidéo",
"vision": true,
"video": true,
"args": {
"file": "texte.txt"
},
"label": "texte.txt",
"result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : texte.txt",
"probes": 0
},
{
"name": "image",
"vision": true,
"video": true,
"args": {
"file": "photo.png"
},
"label": "photo.png",
"result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : photo.png",
"probes": 0
},
{
"name": "introuvable",
"vision": true,
"video": true,
"args": {
"file": "absente.mp4"
},
"label": "absente.mp4",
"result": "[erreur] fichier introuvable : absente.mp4",
"probes": 0
},
{
"name": "dossier",
"vision": true,
"video": true,
"args": {
"file": "dossier.mp4"
},
"label": "dossier.mp4",
"result": "[erreur] c'est un dossier, pas une vidéo : dossier.mp4",
"probes": 0
},
{
"name": "trop lourde",
"vision": true,
"video": true,
"args": {
"file": "lourd.mp4"
},
"label": "lourd.mp4",
"result": "[erreur] vidéo trop lourde (200 Mo, max 200 Mo) — coupe-la en un clip plus court : lourd.mp4",
"probes": 0
},
{
"name": "le moteur ne voit pas",
"vision": true,
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir clip.mp4",
"probes": 1
},
{
"name": "vide",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "vide.mp4"
},
"label": "vide.mp4",
"result": "[erreur] fichier vide : vide.mp4",
"probes": 1
},
{
"name": "voir clip.mp4",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée (clip.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "clip.mp4"
},
"probes": 1
},
{
"name": "voir CLIP.MOV",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "CLIP.MOV"
},
"label": "CLIP.MOV",
"result": "[ok] vidéo chargée : CLIP.MOV\n[relais] Vidéo demandée (CLIP.MOV) :",
"part": {
"mime": "video/quicktime",
"same_as": "CLIP.MOV"
},
"probes": 1
},
{
"name": "voir anim.webm",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "anim.webm"
},
"label": "anim.webm",
"result": "[ok] vidéo chargée : anim.webm\n[relais] Vidéo demandée (anim.webm) :",
"part": {
"mime": "video/webm",
"same_as": "anim.webm"
},
"probes": 1
},
{
"name": "voir film.mkv",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "film.mkv"
},
"label": "film.mkv",
"result": "[ok] vidéo chargée : film.mkv\n[relais] Vidéo demandée (film.mkv) :",
"part": {
"mime": "video/x-matroska",
"same_as": "film.mkv"
},
"probes": 1
},
{
"name": "voir vieux.avi",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "vieux.avi"
},
"label": "vieux.avi",
"result": "[ok] vidéo chargée : vieux.avi\n[relais] Vidéo demandée (vieux.avi) :",
"part": {
"mime": "video/x-msvideo",
"same_as": "vieux.avi"
},
"probes": 1
},
{
"name": "voir sous/court.mp4",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "sous/court.mp4"
},
"label": "sous/court.mp4",
"result": "[ok] vidéo chargée : court.mp4\n[relais] Vidéo demandée (sous/court.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "sous/court.mp4"
},
"probes": 1
},
{
"name": "chemin absolu",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "{{FICHIERS}}/clip.mp4"
},
"label": "{{FICHIERS}}/clip.mp4",
"result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée ({{FICHIERS}}/clip.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "clip.mp4"
},
"probes": 1
}
]
}
+231
View File
@@ -0,0 +1,231 @@
//! see_video (repris d'AJEAN) tenu par la façade, rejoué sur le journal du
//! Go (`internal/loki/parity_video_tool_test.go` →
//! `tests/parity_images/video.json`) : mêmes fichiers, même faux moteur
//! (`/props`), même capacité vidéo du moteur, étape par étape. La vidéo
//! relayée se compare à l'octet (jamais réencodée).
mod common;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use bytes::Bytes;
use common::fakego;
use http_body_util::Full;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::imagetools::{self, VisionProbe};
use loki_rs::store::Store;
use serde_json::{Map, Value, json};
use tokio::net::TcpListener;
/// Le faux moteur : `/props` authentifié, la vision réglée par le test.
async fn fake_engine(sees: Arc<Mutex<(bool, u32)>>) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = l.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let sees = sees.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let sees = sees.clone();
async move {
let ok = req.uri().path() == "/props"
&& req
.headers()
.get("authorization")
.and_then(|h| h.to_str().ok())
== Some("Bearer cle-moteur");
let mut r = Response::new(Full::new(Bytes::new()));
if ok {
let mut s = sees.lock().unwrap();
s.1 += 1;
r = Response::new(Full::new(Bytes::from(
json!({ "modalities": { "vision": s.0 } }).to_string(),
)));
} else {
*r.status_mut() = hyper::StatusCode::UNAUTHORIZED;
}
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(st), svc)
.await;
});
}
});
port
}
/// `parityVideoFacts`.
fn part_facts(part: &Value, raw: &Map<String, Value>) -> Value {
let u = part["input_video"]["url"].as_str().unwrap();
let i = u.find(";base64,").unwrap();
let b = base64::engine::general_purpose::STANDARD
.decode(&u[i + 8..])
.unwrap();
let mut out = Map::new();
out.insert("mime".into(), json!(u[..i].trim_start_matches("data:")));
for (name, r) in raw {
let rb = base64::engine::general_purpose::STANDARD
.decode(r.as_str().unwrap_or(""))
.unwrap();
if rb == b {
out.insert("same_as".into(), json!(name));
}
}
Value::Object(out)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn see_video_comme_le_go() {
let fx: Value =
serde_json::from_str(include_str!("parity_images/video.json")).expect("journal");
let tmp = tempfile::tempdir().unwrap();
let files = tmp.path().join("fichiers");
let raw = fx["files"].as_object().unwrap();
for (name, b) in raw {
let p = files.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(
&p,
base64::engine::general_purpose::STANDARD
.decode(b.as_str().unwrap_or(""))
.unwrap(),
)
.unwrap();
}
std::fs::create_dir_all(files.join("dossier.mp4")).unwrap();
std::fs::File::create(files.join("lourd.mp4"))
.unwrap()
.set_len((imagetools::MAX_VIDEO_BYTES + 1) as u64)
.unwrap();
let files_s = files.to_string_lossy().replace('\\', "/");
let sees = Arc::new(Mutex::new((false, 0u32)));
let port = fake_engine(sees.clone()).await;
let (_, go, _stop) = fakego::start(tmp.path().join("home")).await;
let backend = go.backend.lock().unwrap().unwrap();
let store = Store::new(backend, fakego::JETON.into());
go.set("state", "api_key", "cle-moteur");
let mut cfg = std::collections::HashMap::new();
cfg.insert("PORT".to_string(), port.to_string());
let probe = VisionProbe::default();
for st in fx["steps"].as_array().unwrap() {
let name = st["name"].as_str().unwrap();
*sees.lock().unwrap() = (st["sees"] == true, 0);
probe.reset().await;
let label = st["label"]
.as_str()
.unwrap()
.replace("{{FICHIERS}}", &files_s);
let (mut result, vid) = imagetools::see_video(
&label,
&files,
"",
st["vision"] == true,
st["video"] == true,
probe.sees(&store, &cfg),
)
.await;
let relay = vid.map(|v| loki_rs::chat::host::see_video_message(&label, v));
let mut facts = None;
if let Some(parts) = relay
.as_ref()
.and_then(|m| m.content.as_ref())
.and_then(Value::as_array)
{
for p in parts {
if p["type"] == "input_video" {
facts = Some(part_facts(p, raw));
}
if p["type"] == "text" {
result += &format!("\n[relais] {}", p["text"].as_str().unwrap());
}
}
}
assert_eq!(
result.replace(&files_s, "{{FICHIERS}}"),
st["result"].as_str().unwrap(),
"{name}"
);
assert_eq!(facts, st.get("part").cloned(), "{name} : vidéo relayée");
assert_eq!(
sees.lock().unwrap().1 as u64,
st["probes"].as_u64().unwrap(),
"{name} : sondes du moteur"
);
}
}
/// `videoInputSupported` : jamais pour une API externe, Strata, un preset
/// sans projecteur ou sans moteur ; sinon l'aide du binaire fait foi, relue
/// quand il change.
#[test]
fn capacite_video_du_moteur() {
let tmp = tempfile::tempdir().unwrap();
let cfg = |kv: &[(&str, &str)]| -> std::collections::HashMap<String, String> {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
for c in [
cfg(&[("BIN", "llama-server"), ("MODEL", "m.gguf")]),
cfg(&[("MMPROJ", "mm.gguf"), ("MODEL", "m.gguf")]),
cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "absent/llama-server")]),
cfg(&[
("MMPROJ", "mm.gguf"),
("EXTERNAL", "1"),
("EXTERNAL_VISION", "1"),
]),
cfg(&[
("MMPROJ", "mm.gguf"),
("ENGINE", "strata"),
("BIN", "/bin/sh"),
]),
] {
assert!(
!imagetools::video_input_supported(&c, tmp.path()),
"{c:?} : vidéo annoncée"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let bin = tmp.path().join("llama-server");
let write = |help: &str, age: u64| {
std::fs::write(&bin, format!("#!/bin/sh\necho '{help}'\n")).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let f = std::fs::File::options().write(true).open(&bin).unwrap();
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age))
.unwrap();
};
let c = cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "llama-server")]);
write("--mmproj FILE", 3600);
assert!(!imagetools::video_input_supported(&c, tmp.path()));
write("--video-fps N target fps", 0);
assert!(
imagetools::video_input_supported(&c, tmp.path()),
"moteur mis à jour : --video-fps non relu"
);
}
}
#[test]
fn types_de_video() {
for (f, m) in [
("a.mp4", "video/mp4"),
("A.MOV", "video/quicktime"),
("x/b.webm", "video/webm"),
("c.mkv", "video/x-matroska"),
("d.avi", "video/x-msvideo"),
("e.png", ""),
("mp4", ""),
] {
assert_eq!(imagetools::video_mime(f), m, "{f}");
}
}