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:
Claude committed 2026-10-16 03:51:09 +00:00
1 parent 869bd18bff
commit e94b625931
14 files changed
+7946 -18

No files matched your search

+1
View File
@@ -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
+63 -5
View File
@@ -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
+58
View File
@@ -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"));
}