mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Lot 37 : outils task_* de l'agent exécutés par la façade
Quand la façade tient les tâches (LOKI_TASKS=rust), les quatre outils par lesquels le modèle gère ses tâches planifiées — task_list, task_create, task_update, task_delete — s'exécutent dans ses tours (src/tasks/tools.rs, port de tasks_tools.go), sans passer par le Go : mêmes messages, projet actif, fuseau de l'utilisateur, tâche unique « dans N minutes » ou « à HH:MM », fréquence validée, script vérifié, compte-rendu coupé comme le Go. ChatShared porte la même base de tâches que les routes. Côté Go, les outils prennent l'heure des tâches (tasksNow) au lieu de time.Now : rien ne change en service, le journal de parité la fixe. Parité : journal de 44 appels tiré du Go (parity_tasks_tools_test.go → tests/parity_tasks/tools.json), rejoué par tests/tasks.rs ; un tour de la façade qui crée une tâche (tests/lot6.rs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
14 files changed
+7946
-18
No files matched your search
@@ -240,7 +240,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust ; les jobs du Go (compilation, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
|
||||
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go |
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 37 — Outils task_* de l'agent en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 26, la façade tient les tâches planifiées (`LOKI_TASKS=rust`) :
|
||||
|
||||
- les routes ;
|
||||
- le planificateur ;
|
||||
- les tâches « script seul ».
|
||||
|
||||
Les quatre outils par lesquels le modèle gère lui-même ses tâches
|
||||
(`tasks_tools.go`) restaient au Go, appelés par `/internal/v1/tool/exec` :
|
||||
|
||||
- `task_list` ;
|
||||
- `task_create` ;
|
||||
- `task_update` ;
|
||||
- `task_delete`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/tasks/tools.rs` porte les quatre outils, mêmes messages au caractère
|
||||
près :
|
||||
|
||||
- **`task_list`** :
|
||||
- liste du projet actif, aperçu d'une ligne par tâche ;
|
||||
- compte-rendu coupé à 300 octets, un caractère coupé valant U+FFFD par
|
||||
octet comme à l'encodage JSON du Go ;
|
||||
- avec un id, la tâche seule et son compte-rendu complet ;
|
||||
- une tâche d'un autre projet est « introuvable » ;
|
||||
- **`task_create`** :
|
||||
- tâche unique « dans N minutes » (durée tronquée comme
|
||||
`time.Duration(v)`) ou « à HH:MM » (le lendemain si l'heure est passée,
|
||||
à la même heure murale) ou « à AAAA-MM-JJ HH:MM » ;
|
||||
- fréquence validée ;
|
||||
- script vérifié dans le dossier protégé ;
|
||||
- tâche rattachée au projet actif ;
|
||||
- fuseau : celui du navigateur, sinon celui de la première tâche qui en a
|
||||
un ;
|
||||
- **`task_update`** : modification partielle ; la prochaine exécution est
|
||||
recalculée sur une nouvelle fréquence, ou à la reprise d'une tâche qui
|
||||
n'en a pas ;
|
||||
- **`task_delete`**.
|
||||
|
||||
Un projet disparu vaut le projet par défaut (`taskProjectOf`).
|
||||
|
||||
`GoTurnHost::exec_tool` les exécute quand la façade tient les tâches
|
||||
(`ChatShared.tasks`, la même base que celle des routes). Sinon l'appel part
|
||||
au Go comme avant.
|
||||
|
||||
Côté Go, les outils prennent l'heure des tâches (`tasksNow`) au lieu de
|
||||
`time.Now`. Rien ne change en service ; le journal de parité peut ainsi la
|
||||
fixer.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tasks_tools_test.go` tire du Go un journal de 44
|
||||
appels (`tests/parity_tasks/tools.json`). Chaque étape porte :
|
||||
|
||||
- l'heure, le fuseau, le projet actif, les projets connus ;
|
||||
- les scripts présents ;
|
||||
- une tâche posée telle quelle en base ;
|
||||
- les arguments tels que le modèle les envoie ;
|
||||
- le résultat tel qu'il le reçoit ;
|
||||
- les tâches en base après coup.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- l'heure du trou du changement d'heure ;
|
||||
- une date passée ou impossible ;
|
||||
- des arguments d'un autre type ;
|
||||
- un script absent, hostile ou dossier ;
|
||||
- le compte-rendu coupé au milieu d'un caractère ;
|
||||
- le fuseau d'une tâche existante.
|
||||
|
||||
`tests/tasks.rs` (`parite_des_outils`) le rejoue contre le port Rust.
|
||||
|
||||
`tests/lot6.rs` (`outil_task_par_la_facade`) fait tourner un tour de la
|
||||
façade qui crée une tâche. Il vérifie :
|
||||
|
||||
- rien ne passe par le Go ;
|
||||
- la tâche est rangée au fuseau de l'utilisateur, dans le projet actif ;
|
||||
- le résultat est celui que le modèle reçoit.
|
||||
@@ -0,0 +1,244 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parity_tasks_tools_test.go — journal de référence des outils task_* de
|
||||
// l'agent (lot 37 de la migration Rust) : chaque appel (arguments tels que le
|
||||
// modèle les envoie) et son résultat, avec les tâches en base après coup.
|
||||
// L'heure des tâches est fixée pas à pas ; le fuseau de l'utilisateur, le
|
||||
// projet actif, les projets connus et les scripts du dossier protégé sont
|
||||
// posés avant l'appel. Rangé dans
|
||||
// rust/loki-rs/tests/parity_tasks/tools.json ; tests/tasks.rs le rejoue par
|
||||
// le port Rust.
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilsTaches
|
||||
|
||||
const parityTaskToolsFile = "../../rust/loki-rs/tests/parity_tasks/tools.json"
|
||||
|
||||
type parityTaskToolStep struct {
|
||||
Name string `json:"name"`
|
||||
Now string `json:"now,omitempty"` // RFC 3339 : l'heure des tâches
|
||||
State map[string]string `json:"state,omitempty"`
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
PutTask string `json:"put_task,omitempty"` // une tâche écrite telle quelle en base
|
||||
Tool string `json:"tool"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
Out string `json:"out"`
|
||||
Tasks any `json:"tasks"`
|
||||
}
|
||||
|
||||
func TestPariteOutilsTaches(t *testing.T) {
|
||||
home := testHome(t)
|
||||
oldNow := tasksNow
|
||||
var now time.Time
|
||||
tasksNow = func() time.Time { return now }
|
||||
t.Cleanup(func() { tasksNow = oldNow })
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
})
|
||||
|
||||
// Les identifiants tirés au hasard deviennent {{ID1}}, {{ID2}}… dans
|
||||
// l'ordre où ils apparaissent.
|
||||
ids := map[string]string{}
|
||||
var order []string
|
||||
mask := func(s string) string {
|
||||
return parityTaskIDRe.ReplaceAllStringFunc(s, func(id string) string {
|
||||
if _, ok := ids[id]; !ok {
|
||||
order = append(order, id)
|
||||
ids[id] = "{{ID" + strconv.Itoa(len(order)) + "}}"
|
||||
}
|
||||
return ids[id]
|
||||
})
|
||||
}
|
||||
unmask := func(s string) string {
|
||||
for id, m := range ids {
|
||||
s = strings.ReplaceAll(s, m, id)
|
||||
}
|
||||
return s
|
||||
}
|
||||
snapshot := func() any {
|
||||
b, _ := json.Marshal(listTasks())
|
||||
var v any
|
||||
_ = json.Unmarshal([]byte(mask(string(b))), &v)
|
||||
return v
|
||||
}
|
||||
|
||||
var steps []parityTaskToolStep
|
||||
run := func(st parityTaskToolStep) {
|
||||
if st.Now != "" {
|
||||
n, err := time.Parse(time.RFC3339, st.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = n
|
||||
}
|
||||
parityApplyFiles(t, home, st.Files)
|
||||
for k, v := range st.State {
|
||||
if err := putStr(bkState, k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k == stActiveProjectKey {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = v
|
||||
activeProjMu.Unlock()
|
||||
}
|
||||
}
|
||||
if st.PutTask != "" {
|
||||
raw := unmask(st.PutTask)
|
||||
var tk Task
|
||||
if err := json.Unmarshal([]byte(raw), &tk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := putBytes(bkTasks, tk.ID, []byte(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mask(raw)
|
||||
}
|
||||
var args map[string]any
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(unmask(string(st.Args)))))
|
||||
if err := dec.Decode(&args); err != nil {
|
||||
t.Fatalf("%s : arguments illisibles : %v", st.Name, err)
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
res := execTool(context.Background(), st.Tool, args, toolCallLabel(st.Tool, args), Caps{Agent: true})
|
||||
// Le résultat tel que le modèle le reçoit : encodé en JSON (octets
|
||||
// orphelins d'une coupe → U+FFFD).
|
||||
b, _ := json.Marshal(res.Result)
|
||||
var out string
|
||||
_ = json.Unmarshal(b, &out)
|
||||
st.Out = mask(out)
|
||||
st.Tasks = snapshot()
|
||||
steps = append(steps, st)
|
||||
}
|
||||
args := func(s string) json.RawMessage { return json.RawMessage(s) }
|
||||
// Un compte-rendu dont la coupe à 300 octets tombe au milieu d'un « é ».
|
||||
longReport := strings.Repeat("a", 299) + "é et la suite\n sur plusieurs lignes"
|
||||
|
||||
run(parityTaskToolStep{Name: "liste vide", Now: "2026-03-28T12:00:00Z",
|
||||
State: map[string]string{stUserTZKey: "Europe/Paris"},
|
||||
Tool: "task_list", Args: args(`{}`)})
|
||||
run(parityTaskToolStep{Name: "dans 30 minutes", Tool: "task_create",
|
||||
Args: args(`{"name":"Rappel","prompt":"Bois de l'eau","in_minutes":30}`)})
|
||||
run(parityTaskToolStep{Name: "dans 1,9 minute : une seule", Tool: "task_create",
|
||||
Args: args(`{"name":"Bref","prompt":"x","in_minutes":1.9}`)})
|
||||
run(parityTaskToolStep{Name: "à une heure passée : demain", Tool: "task_create",
|
||||
Args: args(`{"name":"Matin","prompt":"Café","at":"08:15"}`)})
|
||||
run(parityTaskToolStep{Name: "à une heure à venir : aujourd'hui", Tool: "task_create",
|
||||
Args: args(`{"name":"Soir","prompt":"Bilan","at":" 21:05 "}`)})
|
||||
run(parityTaskToolStep{Name: "à une date, dans le trou du changement d'heure", Tool: "task_create",
|
||||
Args: args(`{"name":"Nuit","prompt":"x","at":"2026-03-29 02:30"}`)})
|
||||
run(parityTaskToolStep{Name: "à une date passée", Tool: "task_create",
|
||||
Args: args(`{"name":"Hier","prompt":"x","at":"2026-03-27 10:00"}`)})
|
||||
run(parityTaskToolStep{Name: "at illisible", Tool: "task_create",
|
||||
Args: args(`{"name":"X","prompt":"x","at":"25:00"}`)})
|
||||
run(parityTaskToolStep{Name: "at : date impossible", Tool: "task_create",
|
||||
Args: args(`{"name":"X","prompt":"x","at":"2026-02-30 10:00"}`)})
|
||||
run(parityTaskToolStep{Name: "intervalle", Tool: "task_create",
|
||||
Args: args(`{"name":"Veille","prompt":"Regarde les nouvelles","schedule":"@every 2h"}`)})
|
||||
run(parityTaskToolStep{Name: "cron, désactivée", Tool: "task_create",
|
||||
Args: args(`{"name":"Semaine","prompt":"Point","schedule":"0 9 * * 1-5","enabled":false}`)})
|
||||
run(parityTaskToolStep{Name: "quotidienne ancrée", Tool: "task_create",
|
||||
Args: args(`{"name":"Journal","prompt":"Écris","schedule":"@every 1d@09:00","in_minutes":5}`)})
|
||||
run(parityTaskToolStep{Name: "fréquence invalide", Tool: "task_create",
|
||||
Args: args(`{"name":"X","prompt":"x","schedule":"@every 30s"}`)})
|
||||
run(parityTaskToolStep{Name: "sans nom", Tool: "task_create",
|
||||
Args: args(`{"prompt":"x","in_minutes":5}`)})
|
||||
run(parityTaskToolStep{Name: "nom d'un autre type", Tool: "task_create",
|
||||
Args: args(`{"name":42,"prompt":"x","in_minutes":5}`)})
|
||||
run(parityTaskToolStep{Name: "moins d'une minute : pas de fréquence", Tool: "task_create",
|
||||
Args: args(`{"name":"X","prompt":"x","in_minutes":0.5}`)})
|
||||
run(parityTaskToolStep{Name: "ni prompt ni script", Tool: "task_create",
|
||||
Args: args(`{"name":"X","in_minutes":5}`)})
|
||||
run(parityTaskToolStep{Name: "script absent", Tool: "task_create",
|
||||
Args: args(`{"name":"Ménage","script":"menage.sh","schedule":"@every 1d"}`)})
|
||||
run(parityTaskToolStep{Name: "script hostile", Tool: "task_create",
|
||||
Args: args(`{"name":"Ménage","script":"a;b.sh","schedule":"@every 1d"}`)})
|
||||
run(parityTaskToolStep{Name: "script dossier", Files: map[string]string{"scripts/outils/x": "#!/bin/sh\n"},
|
||||
Tool: "task_create", Args: args(`{"name":"Ménage","script":"outils","schedule":"@every 1d"}`)})
|
||||
run(parityTaskToolStep{Name: "script présent", Files: map[string]string{"scripts/menage.sh": "#!/bin/sh\necho ok\n"},
|
||||
Tool: "task_create", Args: args(`{"name":"Ménage","script":" menage.sh ","prompt":"ignoré ?","schedule":"@every 1d","enabled":"false"}`)})
|
||||
|
||||
// Les projets : une tâche d'un autre projet est invisible ; un projet
|
||||
// disparu vaut le projet par défaut.
|
||||
projects := `[{"slug":"generale","name":"Générale","created_at":1},{"slug":"autre","name":"Autre","created_at":2}]`
|
||||
run(parityTaskToolStep{Name: "tâche d'un autre projet", State: map[string]string{stProjectsKey: projects},
|
||||
PutTask: `{"id":"aaaaaaaaaaaa","name":"Autre","prompt":"x","schedule":"@every 1h","tz":"Europe/Paris","preset":"","project":"autre","no_mem":false,"no_web":false,"enabled":true,"last_run":0,"next_run":1774699200000,"last_dur_ms":0,"last_ok":false,"last_error":"","last_report":""}`,
|
||||
Tool: "task_list", Args: args(`{"id":"aaaaaaaaaaaa"}`)})
|
||||
run(parityTaskToolStep{Name: "tâche d'un projet disparu",
|
||||
PutTask: `{"id":"bbbbbbbbbbbb","name":"Antique (fantôme)","prompt":"x","schedule":"@every 1h","tz":"America/New_York","preset":"","project":"fantome","no_mem":false,"no_web":false,"enabled":true,"last_run":1774692000000,"next_run":1774699200000,"last_dur_ms":1200,"last_ok":false,"last_error":"moteur occupé","last_report":""}`,
|
||||
Tool: "task_list", Args: args(`{"id":"bbbbbbbbbbbb"}`)})
|
||||
rb, _ := json.Marshal(longReport)
|
||||
report := string(rb)
|
||||
run(parityTaskToolStep{Name: "compte-rendu long",
|
||||
PutTask: `{"id":"cccccccccccc","name":"Rapport","prompt":"x","schedule":"@every 1d","tz":"Europe/Paris","preset":"","no_mem":false,"no_web":false,"enabled":false,"last_run":1774692000000,"next_run":1774699200000,"last_dur_ms":5000,"last_ok":true,"last_error":"","last_report":` + report + `}`,
|
||||
Tool: "task_list", Args: args(`{}`)})
|
||||
run(parityTaskToolStep{Name: "compte-rendu complet", Tool: "task_list", Args: args(`{"id":" cccccccccccc "}`)})
|
||||
run(parityTaskToolStep{Name: "liste d'un autre projet", State: map[string]string{stActiveProjectKey: "autre"},
|
||||
Tool: "task_list", Args: args(`{}`)})
|
||||
run(parityTaskToolStep{Name: "création dans le projet actif", Tool: "task_create",
|
||||
Args: args(`{"name":"Ici","prompt":"x","schedule":"@every 3h"}`)})
|
||||
run(parityTaskToolStep{Name: "retour au projet par défaut", State: map[string]string{stActiveProjectKey: "generale"},
|
||||
Tool: "task_update", Args: args(`{"id":"aaaaaaaaaaaa","name":"Volée"}`)})
|
||||
|
||||
// Les modifications.
|
||||
run(parityTaskToolStep{Name: "modifier sans id", Now: "2026-03-28T15:00:00Z", Tool: "task_update", Args: args(`{"name":"x"}`)})
|
||||
run(parityTaskToolStep{Name: "modifier un id inconnu", Tool: "task_update", Args: args(`{"id":"ffffffffffff"}`)})
|
||||
run(parityTaskToolStep{Name: "renommer, nouvelle fréquence", Tool: "task_update",
|
||||
Args: args(`{"id":"{{ID1}}","name":" Eau ","prompt":" ","schedule":" @every 45m "}`)})
|
||||
run(parityTaskToolStep{Name: "mettre en pause", Tool: "task_update", Args: args(`{"id":"{{ID1}}","enabled":false}`)})
|
||||
run(parityTaskToolStep{Name: "fréquence refusée", Tool: "task_update", Args: args(`{"id":"{{ID1}}","schedule":"jamais"}`)})
|
||||
run(parityTaskToolStep{Name: "reprendre une tâche sans prochaine exécution",
|
||||
PutTask: `{"id":"dddddddddddd","name":"Dormante","prompt":"x","schedule":"0 8 * * *","tz":"Europe/Paris","preset":"","no_mem":false,"no_web":false,"enabled":false,"last_run":0,"next_run":0,"last_dur_ms":0,"last_ok":false,"last_error":"","last_report":""}`,
|
||||
Tool: "task_update", Args: args(`{"id":"dddddddddddd","enabled":true,"prompt":"Réveil"}`)})
|
||||
run(parityTaskToolStep{Name: "liste après modifications", Tool: "task_list", Args: args(`{}`)})
|
||||
|
||||
// Les suppressions.
|
||||
run(parityTaskToolStep{Name: "supprimer sans id", Tool: "task_delete", Args: args(`{"id":" "}`)})
|
||||
run(parityTaskToolStep{Name: "supprimer d'un autre projet", Tool: "task_delete", Args: args(`{"id":"aaaaaaaaaaaa"}`)})
|
||||
run(parityTaskToolStep{Name: "supprimer", Tool: "task_delete", Args: args(`{"id":"{{ID1}}"}`)})
|
||||
run(parityTaskToolStep{Name: "supprimer deux fois", Tool: "task_delete", Args: args(`{"id":"{{ID1}}"}`)})
|
||||
|
||||
// Sans fuseau du navigateur : celui de la première tâche qui en a un.
|
||||
run(parityTaskToolStep{Name: "fuseau d'une tâche existante", State: map[string]string{stUserTZKey: ""},
|
||||
Tool: "task_create", Args: args(`{"name":"Ailleurs","prompt":"x","at":"23:30"}`)})
|
||||
|
||||
got, err := json.MarshalIndent(steps, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, '\n')
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(parityTaskToolsFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(parityTaskToolsFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(parityTaskToolsFile)
|
||||
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("les outils task_* ne produisent plus le journal rangé dans %s.\n"+
|
||||
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityTaskToolsFile)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ package loki
|
||||
// saveTask), donc une tâche créée par l'IA apparaît et se pilote comme les
|
||||
// autres dans le panneau Tâches.
|
||||
//
|
||||
// Port Rust (lot 37) : rust/loki-rs/src/tasks/tools.rs, employé par les tours
|
||||
// de la façade quand elle tient les tâches (LOKI_TASKS=rust) ; journal de
|
||||
// parité dans parity_tasks_tools_test.go. L'heure est celle des tâches
|
||||
// (tasksNow), fixée par ce journal.
|
||||
//
|
||||
// Gate : réservés au mode agent (EnabledTools). Une tâche n'a de sens que si
|
||||
// l'IA peut agir en arrière-plan ; sans agent elle n'aurait aucun outil pour
|
||||
// livrer son résultat.
|
||||
@@ -215,7 +220,7 @@ func toolTaskCreate(args map[string]any) string {
|
||||
// fuseau de l'utilisateur (AJEAN 0.17.5). Le modèle n'a plus à jongler avec
|
||||
// les fuseaux ni le cron pour un simple rappel.
|
||||
if schedule == "" {
|
||||
now := time.Now().In(loc(tz))
|
||||
now := tasksNow().In(loc(tz))
|
||||
if v, ok := args["in_minutes"].(float64); ok && v >= 1 {
|
||||
schedule = "@once " + now.Add(time.Duration(v)*time.Minute).Format("2006-01-02 15:04")
|
||||
} else if at := strings.TrimSpace(taskStrArg(args["at"])); at != "" {
|
||||
@@ -259,7 +264,7 @@ func toolTaskCreate(args map[string]any) string {
|
||||
// comme elle n'y voit et n'y pilote que ses propres tâches.
|
||||
Project: activeProjectSlug(),
|
||||
}
|
||||
t.NextRun = computeNextRun(schedule, tz, time.Now())
|
||||
t.NextRun = computeNextRun(schedule, tz, tasksNow())
|
||||
if err := saveTask(t); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
@@ -292,12 +297,12 @@ func toolTaskUpdate(args map[string]any) string {
|
||||
return "[erreur] fréquence invalide : " + err.Error()
|
||||
}
|
||||
t.Schedule = sched
|
||||
t.NextRun = computeNextRun(sched, t.TZ, time.Now())
|
||||
t.NextRun = computeNextRun(sched, t.TZ, tasksNow())
|
||||
}
|
||||
if v, ok := args["enabled"].(bool); ok {
|
||||
t.Enabled = v
|
||||
if v && t.NextRun == 0 {
|
||||
t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now())
|
||||
t.NextRun = computeNextRun(t.Schedule, t.TZ, tasksNow())
|
||||
}
|
||||
}
|
||||
if err := saveTask(t); err != nil {
|
||||
|
||||
@@ -1304,6 +1304,7 @@ impl ChatShared {
|
||||
tpl: std::sync::Arc::new(super::tplprobe::TplProbe::new()),
|
||||
prewarm: Default::default(),
|
||||
mcp: None,
|
||||
tasks: None,
|
||||
go_version: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! classées et formulées comme `friendlyLLMError` ;
|
||||
//! - outils fichiers et shell exécutés ici ([`crate::tools`], état partagé
|
||||
//! tenu par le Go), outils MCP par le pool de la façade quand elle le tient
|
||||
//! (lot 36) ; les autres par le Go (`/internal/v1/tool/exec`), avec
|
||||
//! (lot 36), outils task_* quand elle tient les tâches (lot 37) ; les autres
|
||||
//! par le Go (`/internal/v1/tool/exec`), avec
|
||||
//! ce qu'il tire du résultat (capture de la bulle, aperçu du navigateur,
|
||||
//! image relayée) ;
|
||||
//! - résultats complets (« voir plus ») confiés à l'écrivain des discussions ;
|
||||
@@ -334,6 +335,9 @@ pub struct ChatShared {
|
||||
/// Le pool MCP, quand la façade le tient (LOKI_MCP=rust) : les appels
|
||||
/// `mcp__*` du tour y vont directement (lot 36).
|
||||
pub mcp: Option<Arc<crate::mcp::Mcp>>,
|
||||
/// Les tâches, quand la façade les tient (LOKI_TASKS=rust) : les outils
|
||||
/// `task_*` du tour s'exécutent ici (lot 37).
|
||||
pub tasks: Option<Arc<crate::tasks::Tasks>>,
|
||||
/// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue
|
||||
/// une fois.
|
||||
pub go_version: tokio::sync::OnceCell<String>,
|
||||
@@ -645,6 +649,23 @@ impl TurnHost for GoTurnHost {
|
||||
// Outils MCP, quand la façade tient le pool : ce que ferait le Go
|
||||
// (`mcpCall` → /internal/facade/v1/mcp/call), sans ses deux
|
||||
// allers-retours. Le Go n'en tire rien d'autre que le texte.
|
||||
// Outils task_*, quand la façade tient les tâches (lot 37).
|
||||
if crate::tasks::tools::is_tool(name)
|
||||
&& let Some(tasks) = &self.shared.tasks
|
||||
{
|
||||
let dir = tasks.scripts_dir();
|
||||
let env = crate::tasks::tools::ToolEnv {
|
||||
store: &self.shared.store,
|
||||
scripts_dir: &dir,
|
||||
now_ns: tasks.now(),
|
||||
};
|
||||
if let Some(result) = crate::tasks::tools::exec(&env, name, args).await {
|
||||
return ToolOutcome {
|
||||
result,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
if crate::mcp::config::is_mcp_tool(name)
|
||||
&& let Some(mcp) = &self.shared.mcp
|
||||
{
|
||||
|
||||
@@ -262,11 +262,15 @@ fn serve() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
// Un seul pool MCP : celui des routes, et celui des tours de la façade.
|
||||
// Un seul pool MCP, une seule base de tâches : ceux des routes, et ceux
|
||||
// des tours de la façade (outils mcp__* et task_*).
|
||||
let mcp = mcp_rust
|
||||
.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(engine_paths.home.clone())));
|
||||
let tasks = tasks_rust
|
||||
.then(|| std::sync::Arc::new(loki_rs::tasks::Tasks::new(engine_paths.home.clone())));
|
||||
let chat = if chat_rust {
|
||||
match start_chat(backend, &token, &engine_paths.home, mcp.clone()).await {
|
||||
let held = (mcp.clone(), tasks.clone());
|
||||
match start_chat(backend, &token, &engine_paths.home, held).await {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("[loki-rs] conversation : {e}");
|
||||
@@ -368,8 +372,6 @@ fn serve() -> ExitCode {
|
||||
loki_rs::backends::probe::Probe::system(),
|
||||
))
|
||||
});
|
||||
let tasks = tasks_rust
|
||||
.then(|| std::sync::Arc::new(loki_rs::tasks::Tasks::new(engine_paths.home.clone())));
|
||||
let bench = bench_rust
|
||||
.then(|| std::sync::Arc::new(loki_rs::bench::job::Bench::new(engine_paths.clone())));
|
||||
let tune = tune_rust.then(|| {
|
||||
@@ -512,7 +514,10 @@ async fn start_chat(
|
||||
backend: SocketAddr,
|
||||
token: &str,
|
||||
home: &std::path::Path,
|
||||
mcp: Option<std::sync::Arc<loki_rs::mcp::Mcp>>,
|
||||
(mcp, tasks): (
|
||||
Option<std::sync::Arc<loki_rs::mcp::Mcp>>,
|
||||
Option<std::sync::Arc<loki_rs::tasks::Tasks>>,
|
||||
),
|
||||
) -> Result<std::sync::Arc<loki_rs::chat::generate::ChatService>, String> {
|
||||
use loki_rs::chat::{conv::Conversation, generate::ChatService, host::ChatShared, sessions};
|
||||
let store = loki_rs::store::Store::new(backend, token.to_string());
|
||||
@@ -525,6 +530,7 @@ async fn start_chat(
|
||||
let mut shared = ChatShared::for_sessions(&s, store.clone(), home.join("chatimg"));
|
||||
shared.patterns = loki_rs::chat::generate::retry_patterns(&store).await;
|
||||
shared.mcp = mcp;
|
||||
shared.tasks = tasks;
|
||||
eprintln!("[loki-rs] conversation tenue par la façade (LOKI_CHAT=go pour la rendre au Go)");
|
||||
Ok(ChatService::new(
|
||||
std::sync::Arc::new(s),
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn next_after(schedule: &str, tz: &str, from: i64) -> Result<i128, String> {
|
||||
|
||||
/// `time.ParseInLocation("2006-01-02 15:04", s, l)` : l'instant (ns), ou
|
||||
/// rien si la chaîne ne suit pas la forme.
|
||||
fn parse_ymd_hm(s: &str, l: &Location) -> Option<i128> {
|
||||
pub fn parse_ymd_hm(s: &str, l: &Location) -> Option<i128> {
|
||||
let b = s.as_bytes();
|
||||
let digits = |r: &[u8]| r.iter().all(u8::is_ascii_digit);
|
||||
// 2006 : 4 chiffres ; -01 et -02 : 2 chiffres ; 15 : 1 ou 2 ; :04 : 2.
|
||||
@@ -176,7 +176,7 @@ fn next_every(spec: &str, tz: &str, from: i128) -> Result<i128, String> {
|
||||
}
|
||||
|
||||
/// `parseHHMM`.
|
||||
fn parse_hhmm(s: &str) -> Result<(i64, i64), String> {
|
||||
pub fn parse_hhmm(s: &str) -> Result<(i64, i64), String> {
|
||||
let Some((hs, ms)) = s.trim().split_once(':') else {
|
||||
return Err(format!("heure invalide (attendu HH:MM) : {}", quote(s)));
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! planificateur.
|
||||
|
||||
pub mod cron;
|
||||
pub mod tools;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Les outils `task_*` de l'agent (lot 37) — port de `tasks_tools.go` :
|
||||
//! le modèle gère lui-même ses tâches planifiées (rappel, veille, ménage
|
||||
//! périodique), les liste, les ajuste, les supprime. Mêmes règles que le
|
||||
//! panneau Tâches : fréquence validée, prochaine exécution calculée dans le
|
||||
//! fuseau de l'utilisateur, tâche rattachée au projet actif — l'IA ne voit
|
||||
//! et ne pilote que celles de son projet.
|
||||
//!
|
||||
//! La façade les exécute quand elle tient les tâches (`LOKI_TASKS=rust`) ;
|
||||
//! sinon l'appel part au Go.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::cron::{compute_next_run, loc, next_after, parse_hhmm, parse_ymd_hm};
|
||||
use super::{BK_TASKS, Task, get_task, list_tasks, new_task_id, save_task, script_exists};
|
||||
use crate::paths::DEFAULT_PROJECT_SLUG;
|
||||
use crate::store::{BK_STATE, Store};
|
||||
use crate::tz::Location;
|
||||
|
||||
const NS: i64 = 1_000_000_000;
|
||||
const MINUTE: i64 = 60 * NS;
|
||||
|
||||
/// `stUserTZKey` : fuseau IANA du navigateur de l'utilisateur.
|
||||
pub const ST_USER_TZ: &str = "user_tz";
|
||||
|
||||
/// Les outils portés ici.
|
||||
pub fn is_tool(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"task_list" | "task_create" | "task_update" | "task_delete"
|
||||
)
|
||||
}
|
||||
|
||||
/// Ce qu'il faut pour exécuter un outil : la base, le dossier protégé des
|
||||
/// scripts et l'heure des tâches (ns Unix).
|
||||
pub struct ToolEnv<'a> {
|
||||
pub store: &'a Store,
|
||||
pub scripts_dir: &'a Path,
|
||||
pub now_ns: i64,
|
||||
}
|
||||
|
||||
/// Un appel d'outil `task_*` ; `None` pour un autre outil.
|
||||
pub async fn exec(env: &ToolEnv<'_>, name: &str, args: &Map<String, Value>) -> Option<String> {
|
||||
Some(match name {
|
||||
"task_list" => task_list(env, args).await,
|
||||
"task_create" => task_create(env, args).await,
|
||||
"task_update" => task_update(env, args).await,
|
||||
"task_delete" => task_delete(env, args).await,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `strings.TrimSpace`.
|
||||
fn go_trim(s: &str) -> &str {
|
||||
s.trim_matches(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// `taskStrArg` : une chaîne, `""` si absente ou d'un autre type.
|
||||
fn str_arg<'a>(args: &'a Map<String, Value>, k: &str) -> &'a str {
|
||||
args.get(k).and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
/// `Format("2006-01-02 15:04")` d'un instant en ms Unix, dans `l`.
|
||||
fn fmt_ms(ms: i64, l: &Location) -> String {
|
||||
l.civil(ms.div_euclid(1000)).ymd_hm()
|
||||
}
|
||||
|
||||
/// `activeProjectSlug` et `taskProjectOf`, lus une fois par appel.
|
||||
struct Projects {
|
||||
active: String,
|
||||
known: Vec<String>,
|
||||
}
|
||||
|
||||
impl Projects {
|
||||
async fn load(store: &Store) -> Self {
|
||||
Projects {
|
||||
active: crate::memory::active_project(store).await,
|
||||
known: crate::memory::list_projects(store)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|p| p.slug)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `taskProjectOf` : le projet d'une tâche, absent ou inconnu → le
|
||||
/// projet par défaut.
|
||||
fn of(&self, t: &Task) -> String {
|
||||
let s = go_trim(&t.project);
|
||||
if !s.is_empty() && self.known.iter().any(|k| k == s) {
|
||||
return s.to_string();
|
||||
}
|
||||
DEFAULT_PROJECT_SLUG.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// `defaultTaskTZ` : le fuseau du navigateur s'il est connu, sinon celui
|
||||
/// d'une tâche existante, sinon vide (heure locale du serveur).
|
||||
async fn default_task_tz(store: &Store) -> String {
|
||||
let tz = store.get_str(BK_STATE, ST_USER_TZ).await;
|
||||
if !tz.is_empty() {
|
||||
return tz;
|
||||
}
|
||||
list_tasks(store)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|t| !go_trim(&t.tz).is_empty())
|
||||
.map(|t| t.tz)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `tzLabel`.
|
||||
fn tz_label(tz: &str) -> &str {
|
||||
if tz.is_empty() {
|
||||
"heure du serveur"
|
||||
} else {
|
||||
tz
|
||||
}
|
||||
}
|
||||
|
||||
/// `toolTaskList` : avec un id, cette tâche seule et son compte-rendu
|
||||
/// complet ; sinon la liste du projet, un aperçu court par tâche.
|
||||
async fn task_list(env: &ToolEnv<'_>, args: &Map<String, Value>) -> String {
|
||||
let projects = Projects::load(env.store).await;
|
||||
let id = go_trim(str_arg(args, "id"));
|
||||
if !id.is_empty() {
|
||||
return match get_task(env.store, id).await {
|
||||
Some(t) if projects.of(&t) == projects.active => format_task(&t, true),
|
||||
_ => format!("[erreur] tâche introuvable : {id}"),
|
||||
};
|
||||
}
|
||||
let tasks: Vec<Task> = list_tasks(env.store)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|t| projects.of(t) == projects.active)
|
||||
.collect();
|
||||
if tasks.is_empty() {
|
||||
return "[aucune tâche planifiée dans ce projet]".into();
|
||||
}
|
||||
let mut b = String::new();
|
||||
for t in &tasks {
|
||||
b.push_str(&format_task(t, false));
|
||||
b.push('\n');
|
||||
}
|
||||
b.trim_end_matches('\n').to_string()
|
||||
}
|
||||
|
||||
/// `formatTask` : `full`, le compte-rendu entier ; sinon un aperçu d'une
|
||||
/// ligne.
|
||||
pub fn format_task(t: &Task, full: bool) -> String {
|
||||
let state = if t.enabled { "on" } else { "off" };
|
||||
let mut b = format!("- {} [{state}] « {} » — {}", t.id, t.name, t.schedule);
|
||||
let l = loc(&t.tz);
|
||||
if t.next_run > 0 && t.enabled {
|
||||
b.push_str(&format!(" (prochaine : {})", fmt_ms(t.next_run, &l)));
|
||||
}
|
||||
if t.last_run > 0 {
|
||||
let when = fmt_ms(t.last_run, &l);
|
||||
if t.last_ok {
|
||||
b.push_str(&format!(" ✓ dernier run {when}"));
|
||||
} else {
|
||||
b.push_str(&format!(" ✗ dernier run {when} : {}", t.last_error));
|
||||
}
|
||||
let r = go_trim(&t.last_report);
|
||||
if !r.is_empty() {
|
||||
if full {
|
||||
b.push_str("\n compte-rendu complet :\n");
|
||||
b.push_str(r);
|
||||
} else {
|
||||
b.push_str("\n compte-rendu : ");
|
||||
b.push_str(&preview_report(r));
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// `previewReport` : aplati, coupé à 300 octets (un caractère coupé devient
|
||||
/// U+FFFD par octet, comme à l'encodage JSON du Go).
|
||||
pub fn preview_report(s: &str) -> String {
|
||||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
const MAX: usize = 300;
|
||||
if s.len() > MAX {
|
||||
return format!(
|
||||
"{} […] (id pour le compte-rendu complet)",
|
||||
crate::system::go_lossy(&s.as_bytes()[..MAX])
|
||||
);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `toolTaskCreate`.
|
||||
async fn task_create(env: &ToolEnv<'_>, args: &Map<String, Value>) -> String {
|
||||
let name = go_trim(str_arg(args, "name"));
|
||||
let prompt = go_trim(str_arg(args, "prompt"));
|
||||
let script = go_trim(str_arg(args, "script"));
|
||||
let mut schedule = go_trim(str_arg(args, "schedule")).to_string();
|
||||
let tz = default_task_tz(env.store).await;
|
||||
// Tâche unique : « dans N minutes » ou « à HH:MM », calculés ici dans le
|
||||
// fuseau de l'utilisateur.
|
||||
if schedule.is_empty() {
|
||||
let l = loc(&tz);
|
||||
let at = go_trim(str_arg(args, "at"));
|
||||
if let Some(v) = args
|
||||
.get("in_minutes")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| *v >= 1.0)
|
||||
{
|
||||
// time.Duration(v) * time.Minute : tronqué vers zéro.
|
||||
let t = env
|
||||
.now_ns
|
||||
.wrapping_add((v as i64).wrapping_mul(MINUTE))
|
||||
.div_euclid(NS);
|
||||
schedule = format!("@once {}", l.civil(t).ymd_hm());
|
||||
} else if !at.is_empty() {
|
||||
if let Some(t) = parse_ymd_hm(at, &l) {
|
||||
let sec = t.div_euclid(i128::from(NS)) as i64;
|
||||
schedule = format!("@once {}", l.civil(sec).ymd_hm());
|
||||
} else if let Ok((hh, mm)) = parse_hhmm(at) {
|
||||
let now = l.civil(env.now_ns.div_euclid(NS));
|
||||
let mut t = l.date(now.year, now.month, now.day, hh, mm, 0);
|
||||
if i128::from(t) * i128::from(NS) <= i128::from(env.now_ns) {
|
||||
// AddDate(0, 0, 1) : le lendemain, à la même heure murale.
|
||||
let c = l.civil(t);
|
||||
t = l.date(c.year, c.month, c.day + 1, c.hour, c.minute, c.second);
|
||||
}
|
||||
schedule = format!("@once {}", l.civil(t).ymd_hm());
|
||||
} else {
|
||||
return "[erreur] 'at' invalide : « HH:MM » ou « AAAA-MM-JJ HH:MM »".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
if name.is_empty() || schedule.is_empty() {
|
||||
return "[erreur] name obligatoire, et schedule (tâche répétée) ou in_minutes / at (tâche unique)".into();
|
||||
}
|
||||
let mut kind = "";
|
||||
if !script.is_empty() {
|
||||
// Tâche script : le script doit exister dans le dossier protégé.
|
||||
kind = "script";
|
||||
if let Err(e) = script_exists(env.scripts_dir, script) {
|
||||
return format!("[erreur] {e}");
|
||||
}
|
||||
} else if prompt.is_empty() {
|
||||
return "[erreur] fournis soit 'prompt' (tâche IA) soit 'script' (tâche script)".into();
|
||||
}
|
||||
if let Err(e) = next_after(&schedule, &tz, env.now_ns) {
|
||||
return format!("[erreur] fréquence invalide : {e}");
|
||||
}
|
||||
let enabled = args.get("enabled").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut t = Task {
|
||||
id: new_task_id(),
|
||||
name: name.to_string(),
|
||||
prompt: prompt.to_string(),
|
||||
schedule: schedule.clone(),
|
||||
tz: tz.clone(),
|
||||
enabled,
|
||||
kind: kind.to_string(),
|
||||
script: script.to_string(),
|
||||
// L'IA ne crée que DANS son projet.
|
||||
project: crate::memory::active_project(env.store).await,
|
||||
..Default::default()
|
||||
};
|
||||
t.next_run = compute_next_run(&schedule, &tz, env.now_ns);
|
||||
if let Err(e) = save_task(env.store, &t).await {
|
||||
return format!("[erreur] {e}");
|
||||
}
|
||||
let when = if t.next_run > 0 && enabled {
|
||||
format!(
|
||||
", prochaine exécution {} ({})",
|
||||
fmt_ms(t.next_run, &loc(&tz)),
|
||||
tz_label(&tz)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!("[ok] tâche '{name}' créée (id {}){when}", t.id)
|
||||
}
|
||||
|
||||
/// `toolTaskUpdate` : modification partielle.
|
||||
async fn task_update(env: &ToolEnv<'_>, args: &Map<String, Value>) -> String {
|
||||
let id = go_trim(str_arg(args, "id"));
|
||||
if id.is_empty() {
|
||||
return "[erreur] id manquant".into();
|
||||
}
|
||||
let projects = Projects::load(env.store).await;
|
||||
let mut t = match get_task(env.store, id).await {
|
||||
Some(t) if projects.of(&t) == projects.active => t,
|
||||
_ => return format!("[erreur] tâche introuvable : {id}"),
|
||||
};
|
||||
let text = |k: &str| {
|
||||
args.get(k)
|
||||
.and_then(Value::as_str)
|
||||
.map(go_trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
if let Some(v) = text("name") {
|
||||
t.name = v.to_string();
|
||||
}
|
||||
if let Some(v) = text("prompt") {
|
||||
t.prompt = v.to_string();
|
||||
}
|
||||
if let Some(sched) = text("schedule") {
|
||||
if let Err(e) = next_after(sched, &t.tz, env.now_ns) {
|
||||
return format!("[erreur] fréquence invalide : {e}");
|
||||
}
|
||||
t.schedule = sched.to_string();
|
||||
t.next_run = compute_next_run(sched, &t.tz, env.now_ns);
|
||||
}
|
||||
if let Some(v) = args.get("enabled").and_then(Value::as_bool) {
|
||||
t.enabled = v;
|
||||
if v && t.next_run == 0 {
|
||||
t.next_run = compute_next_run(&t.schedule, &t.tz, env.now_ns);
|
||||
}
|
||||
}
|
||||
if let Err(e) = save_task(env.store, &t).await {
|
||||
return format!("[erreur] {e}");
|
||||
}
|
||||
format!("[ok] tâche '{}' (id {}) mise à jour", t.name, t.id)
|
||||
}
|
||||
|
||||
/// `toolTaskDelete`.
|
||||
async fn task_delete(env: &ToolEnv<'_>, args: &Map<String, Value>) -> String {
|
||||
let id = go_trim(str_arg(args, "id"));
|
||||
if id.is_empty() {
|
||||
return "[erreur] id manquant".into();
|
||||
}
|
||||
let projects = Projects::load(env.store).await;
|
||||
let t = match get_task(env.store, id).await {
|
||||
Some(t) if projects.of(&t) == projects.active => t,
|
||||
_ => return format!("[erreur] tâche introuvable : {id}"),
|
||||
};
|
||||
if let Err(e) = env.store.del(BK_TASKS, id).await {
|
||||
return format!("[erreur] {e}");
|
||||
}
|
||||
format!("[ok] tâche '{}' (id {id}) supprimée", t.name)
|
||||
}
|
||||
@@ -1007,6 +1007,7 @@ pub async fn start_with(
|
||||
let mut shared = ChatShared::for_sessions(&s, store.clone(), home.join("chatimg"));
|
||||
shared.patterns = loki_rs::chat::generate::retry_patterns(&store).await;
|
||||
shared.mcp = opts.mcp.clone();
|
||||
shared.tasks = opts.tasks.clone();
|
||||
Some(ChatService::new(Arc::new(s), Arc::new(shared)))
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -668,6 +668,7 @@ async fn tour_complet_par_le_go() {
|
||||
tpl: Arc::new(loki_rs::chat::tplprobe::TplProbe::new()),
|
||||
prewarm: Default::default(),
|
||||
mcp: None,
|
||||
tasks: None,
|
||||
go_version: Default::default(),
|
||||
});
|
||||
let cfg = std::collections::HashMap::new();
|
||||
@@ -851,13 +852,13 @@ async fn service(
|
||||
tokio::sync::oneshot::Sender<()>,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
service_with(engine_url, None).await
|
||||
service_with(engine_url, |_| {}).await
|
||||
}
|
||||
|
||||
/// [`service`], avec le pool MCP de la façade.
|
||||
/// [`service`], l'état partagé réglé par `f` (pool MCP, tâches…).
|
||||
async fn service_with(
|
||||
engine_url: Option<&str>,
|
||||
mcp: Option<Arc<loki_rs::mcp::Mcp>>,
|
||||
f: impl FnOnce(&mut ChatShared),
|
||||
) -> (
|
||||
Arc<ChatService>,
|
||||
Arc<fakego::FakeGo>,
|
||||
@@ -874,7 +875,7 @@ async fn service_with(
|
||||
let sessions = Sessions::new(st.clone(), Conversation::new(), GoSessionHooks(st.clone()));
|
||||
sessions.load().await.unwrap();
|
||||
let mut shared = ChatShared::for_sessions(&sessions, st, t.path().join("chatimg"));
|
||||
shared.mcp = mcp;
|
||||
f(&mut shared);
|
||||
(
|
||||
ChatService::new(Arc::new(sessions), Arc::new(shared)),
|
||||
go,
|
||||
@@ -2443,7 +2444,7 @@ done
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let trace = dir.path().join("trace");
|
||||
let pool = Arc::new(loki_rs::mcp::Mcp::new(dir.path().to_path_buf()));
|
||||
let (svc, go, _stop, _t) = service_with(Some(&url), Some(pool.clone())).await;
|
||||
let (svc, go, _stop, _t) = service_with(Some(&url), |s| s.mcp = Some(pool.clone())).await;
|
||||
let mut servers = loki_rs::mcp::config::Servers::new();
|
||||
servers.insert(
|
||||
"sh".into(),
|
||||
@@ -2493,3 +2494,60 @@ done
|
||||
assert!(inits[1].contains(r#""version":"0.15.0""#), "{inits:?}");
|
||||
pool.close_all().await;
|
||||
}
|
||||
|
||||
/// Lot 37 : un outil task_* appelé par un tour de la façade s'exécute ici
|
||||
/// quand elle tient les tâches — la tâche rangée dans le projet actif, au
|
||||
/// fuseau de l'utilisateur, sans passer par le Go.
|
||||
#[tokio::test]
|
||||
async fn outil_task_par_la_facade() {
|
||||
let call = sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"t1","type":"function","function":{"name":"task_create","arguments":"{\"name\":\"Rappel\",\"prompt\":\"Bois\",\"schedule\":\"@every 2h\"}"}}]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
]);
|
||||
let (url, seen) = fake_engine(vec![call, answer("c'est noté")]).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 2026-03-28 12:00:00 UTC.
|
||||
fn clock() -> i64 {
|
||||
1_774_699_200 * 1_000_000_000
|
||||
}
|
||||
let tasks = Arc::new(loki_rs::tasks::Tasks::with(
|
||||
dir.path().to_path_buf(),
|
||||
std::time::Duration::from_secs(3600),
|
||||
clock,
|
||||
));
|
||||
let (svc, go, _stop, _t) = service_with(Some(&url), |s| s.tasks = Some(tasks.clone())).await;
|
||||
go.set("state", "user_tz", "Europe/Paris");
|
||||
go.set("state", "active_project", "generale");
|
||||
svc.send(serde_json::json!({"message": "rappelle-moi de boire", "cid": "t"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log);
|
||||
let list = loki_rs::tasks::list_tasks(&svc.shared.store).await;
|
||||
assert_eq!(list.len(), 1, "{list:?}");
|
||||
let t = &list[0];
|
||||
assert_eq!(
|
||||
(
|
||||
t.name.as_str(),
|
||||
t.schedule.as_str(),
|
||||
t.tz.as_str(),
|
||||
t.project.as_str()
|
||||
),
|
||||
("Rappel", "@every 2h", "Europe/Paris", "generale")
|
||||
);
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
let tool = seen[1].1["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.rfind(|m| m["role"] == "tool")
|
||||
.unwrap()["content"]
|
||||
.clone();
|
||||
assert_eq!(
|
||||
tool,
|
||||
serde_json::json!(format!(
|
||||
"[ok] tâche 'Rappel' créée (id {}), prochaine exécution 2026-03-28 15:00 (Europe/Paris)",
|
||||
t.id
|
||||
))
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -495,3 +495,61 @@ async fn amorce_au_demarrage() {
|
||||
assert_eq!(next("eteinte"), 0);
|
||||
assert_eq!(next("posee"), 42);
|
||||
}
|
||||
|
||||
fn zero_clock() -> i64 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Lot 37 : les outils task_* de l'agent, rejoués contre le journal du Go
|
||||
/// (`internal/loki/parity_tasks_tools_test.go` → `parity_tasks/tools.json`) :
|
||||
/// chaque appel, son résultat tel que le modèle le reçoit, les tâches en base
|
||||
/// après coup.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn parite_des_outils() {
|
||||
use loki_rs::tasks::tools::{ToolEnv, exec, is_tool};
|
||||
let fx: Value = serde_json::from_str(include_str!("parity_tasks/tools.json")).expect("journal");
|
||||
let mut bn = bench(zero_clock).await;
|
||||
let scripts = bn.home.join("scripts");
|
||||
let mut now = 0;
|
||||
for st in fx.as_array().unwrap() {
|
||||
let name = s(&st["name"]);
|
||||
if let Some(n) = st["now"].as_str() {
|
||||
now = rfc3339_ns(n);
|
||||
}
|
||||
let files: serde_json::Map<String, Value> = st["files"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
|
||||
.collect();
|
||||
apply_files(&bn.home, &Value::Object(files));
|
||||
for (k, v) in st["state"].as_object().into_iter().flatten() {
|
||||
bn.go.set("state", k, s(v));
|
||||
}
|
||||
if let Some(t) = st["put_task"].as_str() {
|
||||
let raw = bn.unmask(t);
|
||||
let v: Value = serde_json::from_str(&raw).unwrap();
|
||||
bn.go.set("tasks", s(&v["id"]), &raw);
|
||||
bn.mask(&raw);
|
||||
}
|
||||
let tool = s(&st["tool"]);
|
||||
assert!(is_tool(tool), "{name}");
|
||||
let args: serde_json::Map<String, Value> =
|
||||
serde_json::from_str(&bn.unmask(&st["args"].to_string())).unwrap();
|
||||
let env = ToolEnv {
|
||||
store: &bn.store,
|
||||
scripts_dir: &scripts,
|
||||
now_ns: now,
|
||||
};
|
||||
let out = exec(&env, tool, &args).await.expect("outil task_*");
|
||||
assert_eq!(bn.mask(&out), s(&st["out"]), "{name}");
|
||||
assert_eq!(bn.snapshot().await, st["tasks"], "{name}");
|
||||
}
|
||||
let env = ToolEnv {
|
||||
store: &bn.store,
|
||||
scripts_dir: &scripts,
|
||||
now_ns: now,
|
||||
};
|
||||
assert_eq!(exec(&env, "bash", &serde_json::Map::new()).await, None);
|
||||
assert!(!is_tool("task_run"));
|
||||
}
|
||||
Reference in new issue
Block a user