mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 21:18:20 +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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user