mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Jusqu'ici, impossible de savoir si une ligne volatile s'était glissée dans le prompt, si une mise à jour du moteur avait cassé les points de reprise ou si l'acceptation du MTP s'était effondrée : le chunk final de llama.cpp porte ces chiffres (cache_n, draft_n, cached_tokens), on les jetait. Lecture seule : aucun champ ajouté aux requêtes, et le comptage du contexte (usage.prompt_tokens → CtxUsed → compaction) ne change pas. - perf_log.go : décodage à part et tolérant (perfWire) — un compteur mal typé ne jette plus le chunk final ni ne fait échouer une compaction ; cached_tokens, strictement entier dans streamChunk, y déménage aussi. - Inconnu n'est pas 0 : pointeurs, et l'UI garde « prefill N tok » quand le moteur ne dit rien de son cache. - Nature (main, subagent, verify, task, compact, bench, foreign) portée par le contexte, pas par Caps ; TTFT au premier delta de tout type. - Perte de cache = total précédent − cache_n, seulement quand les messages prolongent strictement ceux de la complétion précédente de la même discussion et de la même nature (empreintes cumulées) ; jamais sur un flux coupé. Ce qui s'est intercalé (sous-agent, client /v1, bench) est nommé. - Anneau de 5000 entrées en mémoire, sans texte ; GET /api/perf/summary (derrière la clé) : taux de cache, recalcul par tour et par nature, médianes pp/tg par profondeur, acceptation du brouillon. - Ligne [perf] sur stderr seulement avec LOKI_PERF_LOG. - UI : « prefill X nouveaux / Y en cache », ambre au-delà d'un seuil relevé sur un modèle hybride (espacement des points de reprise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
12 KiB
Go
305 lines
12 KiB
Go
package loki
|
|
|
|
// tasks_run.go — exécution d'une tâche, ISOLÉE de la discussion ouverte.
|
|
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté au fonctionnement de loki
|
|
// (dossier de travail par discussion, modes de la machine).
|
|
//
|
|
// Une tâche ne doit ni apparaître dans le chat, ni casser le rejeu SSE, ni entrer
|
|
// en collision avec un tour utilisateur. On ne partage donc qu'UN SEUL point avec
|
|
// la conversation : son verrou de génération (conv.Generating), qui garantit
|
|
// qu'une seule inférence tourne à la fois — llama-server est lancé en
|
|
// --parallel 1. Tout le reste — messages, journal d'affichage — n'est jamais
|
|
// touché : la tâche construit son propre fil éphémère et le jette à la fin, ne
|
|
// gardant que le texte final comme compte-rendu.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// RunAutonomous exécute un prompt en arrière-plan, sans laisser de trace dans la
|
|
// discussion ouverte, et renvoie le texte final produit par l'IA (le compte-rendu).
|
|
// Prend le MÊME verrou que StartTurn : renvoie ErrBusy si un tour (utilisateur ou
|
|
// une autre tâche) est déjà en cours, ou errModelLoading si le modèle n'est pas
|
|
// prêt. caps gouverne l'accès aux outils (agent, mémoire, internet).
|
|
// lastReport est le compte-rendu du passage précédent (vide au premier) : il est
|
|
// réinjecté pour donner à l'IA une continuité d'une exécution à l'autre.
|
|
func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prompt, lastReport string, caps Caps, temperature float64) (string, error) {
|
|
if !healthCheck() {
|
|
return "", errModelLoading
|
|
}
|
|
// Contexte annulable propre à la tâche, exposé via c.cancel : le bouton stop
|
|
// du chat (/api/chat/stop → conv.Stop) interrompt donc VRAIMENT une tâche de
|
|
// fond, au lieu de l'afficher « en cours » sans pouvoir l'arrêter.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
c.mu.Lock()
|
|
if c.Generating {
|
|
c.mu.Unlock()
|
|
return "", ErrBusy
|
|
}
|
|
c.Generating = true
|
|
c.cancel = cancel
|
|
c.runningTaskID = taskID
|
|
c.runningTaskName = taskName
|
|
c.mu.Unlock()
|
|
// Libère le verrou quoi qu'il arrive. On ne touche NI c.Messages NI c.Log NI
|
|
// c.epoch : la discussion ouverte reste totalement à l'écart.
|
|
defer func() {
|
|
c.mu.Lock()
|
|
c.Generating = false
|
|
c.cancel = nil
|
|
c.runningTaskID = ""
|
|
c.runningTaskName = ""
|
|
c.mu.Unlock()
|
|
}()
|
|
|
|
// Dossier de travail de la tâche : le sien, pas celui de la discussion
|
|
// ouverte (chat_workspace.go). Stable d'un passage à l'autre — une tâche qui
|
|
// tient un fichier de suivi le retrouve la fois suivante.
|
|
setTaskWorkspace(taskWorkspaceDir(taskID))
|
|
defer setTaskWorkspace("")
|
|
|
|
if temperature == 0 {
|
|
temperature = 0.7
|
|
}
|
|
// La note de tâche (conscience du mode autonome + compte-rendu du passage
|
|
// précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : le
|
|
// préfixe système + outils reste ainsi identique à celui du chat, et le cache
|
|
// de prompt de llama-server sert aux deux. Une note dans le système faisait
|
|
// tout recalculer à la tâche, PUIS au message suivant de l'utilisateur (12 s
|
|
// mesurées en amont pour un simple « salut », AJEAN 0.17.5).
|
|
if note := taskContextNote(taskName, lastReport); note != "" {
|
|
prompt = note + "\n\n" + prompt
|
|
}
|
|
msgs := []Message{{Role: "user", Content: prompt}}
|
|
// Même préambule que la vraie génération : consigne personnelle + préambule
|
|
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
|
|
// contexte et les mêmes outils qu'en chat.
|
|
// Contexte du projet de la tâche : le projet est déjà forcé par l'appelant
|
|
// (setProjectOverride dans runTask), donc ces messages décrivent le bon
|
|
// chantier — la tâche voit la mémoire et les trackers qu'elle vise.
|
|
final := append(projectSystemMessages(), msgs...)
|
|
if sys := readSysPrompt(); sys != "" {
|
|
final = append([]Message{{Role: "system", Content: sys}}, final...)
|
|
}
|
|
|
|
var content strings.Builder
|
|
// La tâche a pris le slot de la conversation : effacé une fois la tâche
|
|
// finie, verrou de génération encore tenu (llm_slots.go).
|
|
defer engineSideJob()()
|
|
_, err := runChat(withPerf(ctx, perfTask, "task:"+taskID), InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
|
if ev.Content != "" {
|
|
content.WriteString(ev.Content)
|
|
}
|
|
return true
|
|
})
|
|
// Les messages d'outils ne sont pas conservés : la tâche est éphémère, seul
|
|
// son compte-rendu survit.
|
|
return strings.TrimSpace(content.String()), err
|
|
}
|
|
|
|
// taskWorkspaceDir donne (et crée) le dossier de travail d'une tâche, sous la
|
|
// racine du dossier de travail — jamais dans le dossier d'une discussion, dont
|
|
// il partagerait le sort à la suppression.
|
|
func taskWorkspaceDir(taskID string) string {
|
|
root := agentWorkspace()
|
|
if taskID == "" {
|
|
return root
|
|
}
|
|
dir := filepath.Join(root, "tasks", taskID)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return root
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// taskContextNote construit la note de contexte injectée en tête du fil d'une
|
|
// tâche : d'abord la conscience du mode (l'IA tourne seule, sans utilisateur en
|
|
// face), puis — s'il existe — le compte-rendu du passage précédent, pour la
|
|
// continuité.
|
|
func taskContextNote(taskName, lastReport string) string {
|
|
var b strings.Builder
|
|
b.WriteString("[Tâche planifiée")
|
|
if taskName != "" {
|
|
b.WriteString(" « " + taskName + " »")
|
|
}
|
|
b.WriteString("] Tu es exécuté automatiquement en arrière-plan, sans utilisateur " +
|
|
"présent pour te répondre. Mène la tâche à son terme de façon autonome, puis " +
|
|
"termine par un compte-rendu clair et court de ce que tu as fait ou trouvé.")
|
|
if r := strings.TrimSpace(lastReport); r != "" {
|
|
if len(r) > reportMax {
|
|
r = r[:reportMax] + "…"
|
|
}
|
|
b.WriteString("\n\nCompte-rendu de ta dernière exécution (pour la continuité — " +
|
|
"appuie-toi dessus, ne le répète pas tel quel) :\n" + r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// reportMax borne la taille du compte-rendu conservé : le texte final peut être
|
|
// long, on n'en garde qu'un aperçu pour l'interface et pour la relance suivante.
|
|
const reportMax = 4000
|
|
|
|
// runTask exécute une tâche et met à jour son état persisté (dernier passage,
|
|
// prochain passage, succès/échec, compte-rendu).
|
|
func runTask(t Task) {
|
|
start := time.Now()
|
|
|
|
// Tâche « script seul » : aucun modèle à charger, aucun token consommé — on
|
|
// lance le script du dossier protégé et sa sortie devient le compte-rendu.
|
|
// Testé AVANT le preset : basculer le moteur pour un script serait absurde.
|
|
if t.Kind == "script" {
|
|
runScriptTask(t)
|
|
return
|
|
}
|
|
|
|
// Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement
|
|
// du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai
|
|
// échec de la tâche : la faire tourner sur le mauvais modèle serait pire.
|
|
if err := ensureTaskPreset(t.Preset); err != nil {
|
|
recordTaskEnd(t.ID, start, "", fmt.Errorf("changement de preset : %w", err))
|
|
return
|
|
}
|
|
|
|
// Projet visé : on force le projet vu par memoryDir() et par les trackers, le
|
|
// temps du passage. Sans ça, une veille rattachée au projet « NAS » écrirait
|
|
// dans la mémoire du projet ouvert à l'écran au moment du tic. Sûr : une seule
|
|
// inférence tourne à la fois (verrou de génération), et RunAutonomous a déjà
|
|
// rendu la main quand on libère.
|
|
setProjectOverride(taskProjectOf(t))
|
|
defer setProjectOverride("")
|
|
|
|
report, err := conv.RunAutonomous(context.Background(), t.ID, t.Name, t.Prompt, t.LastReport, taskCaps(t), 0)
|
|
|
|
// Occupé, ou modèle pas encore prêt : ce n'est pas un échec de la tâche, juste
|
|
// un mauvais moment. On ne touche pas à son état (NextRun reste dans le passé)
|
|
// pour qu'elle soit réessayée au tick suivant.
|
|
if err == ErrBusy || err == errModelLoading {
|
|
return
|
|
}
|
|
recordTaskEnd(t.ID, start, report, err)
|
|
}
|
|
|
|
// taskCaps dérive les capacités d'une tâche : on part du mode agent de la machine
|
|
// (qui débloque les outils), puis on applique les réglages propres à la tâche pour
|
|
// la mémoire et le web. Le web reste borné à ce que la machine offre réellement
|
|
// (serveur Crawl4AI configuré et joignable) : une tâche ne peut pas l'inventer.
|
|
//
|
|
// Le mode code reste OFF : c'est un mode de discussion (rôles, critères,
|
|
// vérification, puces dans le fil) qui n'a pas de sens sans personne en face.
|
|
func taskCaps(t Task) Caps {
|
|
// Mem posé EXPLICITEMENT à MemOff : le zéro de MemMode est la chaîne vide,
|
|
// que EnabledTools ne reconnaît pas comme « coupée » (il compare à MemOff) —
|
|
// une tâche sans agent se serait donc vu offrir les outils mem_*.
|
|
c := Caps{Agent: agentEnabled(), Mem: MemOff}
|
|
if !c.Agent {
|
|
return c // agent coupé : aucun outil, mémoire et web n'ont plus de sens
|
|
}
|
|
if t.NoWeb {
|
|
c.Internet = false
|
|
} else {
|
|
c.Internet = internetEnabled() && crawlReachable()
|
|
}
|
|
if t.NoMem {
|
|
c.Mem = MemOff
|
|
} else if m := memMode(); m == MemOff {
|
|
// La tâche veut la mémoire mais la machine l'a coupée globalement : on donne
|
|
// au moins les outils à la demande, sans l'injection proactive.
|
|
c.Mem = MemOnDemand
|
|
} else {
|
|
c.Mem = m
|
|
}
|
|
return c
|
|
}
|
|
|
|
// ensureTaskPreset bascule le moteur sur le preset d'id `id` s'il n'est pas déjà
|
|
// actif, puis attend que le modèle ait rechargé. Vide = rien à faire.
|
|
func ensureTaskPreset(id string) error {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
list, err := ListPresets()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var target *Preset
|
|
for i := range list {
|
|
if list[i].ID == id {
|
|
target = &list[i]
|
|
break
|
|
}
|
|
}
|
|
if target == nil {
|
|
return fmt.Errorf("preset introuvable : %s", id)
|
|
}
|
|
if target.Active {
|
|
return nil
|
|
}
|
|
if err := SwitchToPreset(target.Path); err != nil {
|
|
return err
|
|
}
|
|
// Le moteur redémarre : on attend qu'il réponde à nouveau (charger un gros
|
|
// modèle prend un moment).
|
|
deadline := time.Now().Add(4 * time.Minute)
|
|
for time.Now().Before(deadline) {
|
|
if healthCheck() {
|
|
return nil
|
|
}
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
return fmt.Errorf("le modèle n'a pas fini de charger après le changement de preset")
|
|
}
|
|
|
|
// recordTaskEnd met à jour l'état persisté d'une tâche après une exécution :
|
|
// durée, dernier passage, prochain passage, succès/échec, compte-rendu. Relit la
|
|
// tâche juste avant d'écrire pour ne pas écraser une modification concurrente
|
|
// (bascule ou édition depuis l'interface pendant l'exécution).
|
|
func recordTaskEnd(id string, start time.Time, report string, err error) {
|
|
now := time.Now()
|
|
cur, ok := getTask(id)
|
|
if !ok {
|
|
return // supprimée entre-temps : rien à écrire
|
|
}
|
|
cur.LastDurMs = now.Sub(start).Milliseconds()
|
|
cur.LastRun = now.UnixMilli()
|
|
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
|
|
if isOnce(cur.Schedule) {
|
|
cur.Enabled, cur.NextRun = false, 0 // passée une fois : terminée
|
|
}
|
|
if errors.Is(err, context.Canceled) {
|
|
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
|
|
// « interrompue » sans allumer l'indicateur rouge d'erreur.
|
|
cur.LastOK = true
|
|
cur.LastError = ""
|
|
cur.LastReport = "(interrompue manuellement)"
|
|
} else if err != nil {
|
|
cur.LastOK = false
|
|
cur.LastError = err.Error()
|
|
} else {
|
|
cur.LastOK = true
|
|
cur.LastError = ""
|
|
if len(report) > reportMax {
|
|
report = report[:reportMax] + "…"
|
|
}
|
|
cur.LastReport = report
|
|
}
|
|
_ = saveTask(cur)
|
|
// Notification Web Push : une tâche planifiée tourne SANS personne devant
|
|
// l'écran — c'est le cas où être prévenu compte le plus. Détaché (l'envoi
|
|
// HTTP ne doit pas retenir le planificateur) et silencieux sur une
|
|
// interruption manuelle : celui qui a cliqué « stop » est déjà là.
|
|
if hasPushSubs() && !errors.Is(err, context.Canceled) {
|
|
title, body := "Loki · tâche terminée", cur.Name
|
|
if err != nil {
|
|
title = "Loki · tâche en échec"
|
|
}
|
|
go sendPushToAll(title, body)
|
|
}
|
|
}
|