mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 12:16:21 +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
@@ -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