mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 22:18:13 +02:00
Lot 7 : outils fichiers et shell exécutés par la façade
Quand la façade tient la conversation (LOKI_CHAT=rust), read, grep, glob, write, edit, bash et ask s'exécutent dans rust/loki-rs/src/tools au lieu de repartir au Go par /internal/v1/tool/exec : mêmes garde-fous dans le même ordre (commande catastrophique, « & » final en mode Code, bac à sable du mode Code, dossier mémoire interdit, lu avant d'écrire), même diff pour l'UI, même nettoyage de la sortie du shell. Le jeton de l'API interne est retiré de l'environnement du shell de l'agent. L'état partagé reste au Go, en un seul exemplaire (sous-agents et tâches le lisent aussi) : POST /internal/v1/tool/context (dossier de travail, dossier mémoire, cible distante), /tool/tracker (lecture notée, contrôle avant écriture), /tool/wrote (note après écriture, diagnostics LSP). Repli sur le Go pour une cible distante ou un contexte indisponible. Parité : internal/loki/parity_tools_test.go passe 58 appels à execTool sur une arborescence de test et range le journal dans tests/parity_tools ; tests/tools.rs le retrouve à l'identique avec exec_local. Écarts assumés consignés dans docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
18 files changed
+3560
-2
No files matched your search
@@ -46,6 +46,10 @@ pub struct FakeGo {
|
||||
pub turn_log: Mutex<Vec<String>>,
|
||||
/// Adresse d'écoute du faux Go (pour un client `Store` direct).
|
||||
pub backend: Mutex<Option<SocketAddr>>,
|
||||
/// Contexte des outils exécutés par la façade (None : 404, le Go les
|
||||
/// exécute), et journal du suivi (lectures, écritures, contrôles).
|
||||
pub tool_ctx: Mutex<Option<serde_json::Value>>,
|
||||
pub tracker_log: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeGo {
|
||||
@@ -234,6 +238,23 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
*st.go_busy.lock().unwrap() = None;
|
||||
resp(204, "")
|
||||
}
|
||||
"/internal/v1/tool/context" => match st.tool_ctx.lock().unwrap().clone() {
|
||||
Some(v) => resp(200, v.to_string()),
|
||||
None => resp(404, "404 page not found"),
|
||||
},
|
||||
"/internal/v1/tool/tracker" | "/internal/v1/tool/wrote" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let op = v["op"].as_str().unwrap_or("wrote").to_string();
|
||||
let path = v["path"].as_str().unwrap_or("").to_string();
|
||||
st.tracker_log.lock().unwrap().push(format!("{op} {path}"));
|
||||
let out = if op == "check" {
|
||||
serde_json::json!({ "refusal": "" })
|
||||
} else {
|
||||
serde_json::json!({ "lsp": "" })
|
||||
};
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/tool/exec" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
@@ -799,6 +799,51 @@ async fn tour_de_bout_en_bout() {
|
||||
assert!(again.dup);
|
||||
}
|
||||
|
||||
/// Lot 7 : un `read` s'exécute dans la façade, dans le dossier de travail que
|
||||
/// lui donne le Go, et la lecture est notée dans le suivi du Go.
|
||||
#[tokio::test]
|
||||
async fn outil_execute_par_la_facade() {
|
||||
let ws = tempfile::tempdir().unwrap();
|
||||
std::fs::write(ws.path().join("notes.txt"), "première\nseconde\n").unwrap();
|
||||
let script = vec![
|
||||
sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"read","arguments":"{\"file\":\"notes.txt\"}"}}]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
]),
|
||||
sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"content": "lu"}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"stop"}]}),
|
||||
]),
|
||||
];
|
||||
let (url, seen) = fake_engine(script).await;
|
||||
let (svc, go, _stop, _t) = service(Some(&url)).await;
|
||||
*go.tool_ctx.lock().unwrap() = Some(serde_json::json!({
|
||||
"cwd": ws.path(),
|
||||
"memory_dir": ws.path().join("memory"),
|
||||
"target": "",
|
||||
}));
|
||||
svc.send(serde_json::json!({"message": "lis", "cid": "b1"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
let tool = deltas(&svc, "tool_used")
|
||||
.into_iter()
|
||||
.find(|t| t["done"] == true)
|
||||
.unwrap();
|
||||
assert_eq!(tool["result"], "1: première\n2: seconde");
|
||||
// Le résultat est reparti vers le moteur, sans passer par /tool/exec.
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
seen[1].1["messages"].as_array().unwrap().last().unwrap()["content"],
|
||||
"1: première\n2: seconde"
|
||||
);
|
||||
let notes = ws.path().join("notes.txt");
|
||||
assert_eq!(
|
||||
*go.tracker_log.lock().unwrap(),
|
||||
[format!("read {}", notes.display())]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_en_file_injecte_dans_le_tour() {
|
||||
let gate = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
{
|
||||
"cwd": "ws",
|
||||
"memory_dir": "memory/generale",
|
||||
"files": [
|
||||
{
|
||||
"path": "ws/a.txt",
|
||||
"content": "ligne 1\nligne 2\nligne 3\n",
|
||||
"age": 50
|
||||
},
|
||||
{
|
||||
"path": "ws/b.txt",
|
||||
"content": "alpha\nbeta\n",
|
||||
"age": 40
|
||||
},
|
||||
{
|
||||
"path": "ws/vide.txt",
|
||||
"content": "",
|
||||
"age": 30
|
||||
},
|
||||
{
|
||||
"path": "ws/long.txt",
|
||||
"content": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\nfin\n",
|
||||
"age": 20
|
||||
},
|
||||
{
|
||||
"path": "ws/crlf.txt",
|
||||
"content": "un\r\ndeux\r\ntrois\r\n",
|
||||
"age": 10
|
||||
},
|
||||
{
|
||||
"path": "ws/bin.dat",
|
||||
"content": "PK\u0000\u0001binaire",
|
||||
"age": 60
|
||||
},
|
||||
{
|
||||
"path": "ws/src/main.go",
|
||||
"content": "package main\n\nfunc main() { x := 1 }\n",
|
||||
"age": 5
|
||||
},
|
||||
{
|
||||
"path": "ws/src/lib/util.go",
|
||||
"content": "package lib\n// x marque\n",
|
||||
"age": 3
|
||||
},
|
||||
{
|
||||
"path": "ws/src/lib/notes.md",
|
||||
"content": "x dans les notes\n",
|
||||
"age": 4
|
||||
},
|
||||
{
|
||||
"path": "ws/node_modules/dep/index.js",
|
||||
"content": "ligne cachée\n",
|
||||
"age": 70
|
||||
},
|
||||
{
|
||||
"path": "ws/beaucoup.txt",
|
||||
"content": "aiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\n",
|
||||
"age": 80
|
||||
},
|
||||
{
|
||||
"path": "dehors.txt",
|
||||
"content": "hors du dossier\n",
|
||||
"age": 90
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "lecture",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "a.txt"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "1: ligne 1\n2: ligne 2\n3: ligne 3"
|
||||
},
|
||||
{
|
||||
"name": "lecture_fenetre",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"limit": 1,
|
||||
"offset": 2
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "2: ligne 2\n… (1 lignes restantes — relis avec offset=3)"
|
||||
},
|
||||
{
|
||||
"name": "lecture_fenetre_decimale",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"limit": "3",
|
||||
"offset": 2.7
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "2: ligne 2\n3: ligne 3"
|
||||
},
|
||||
{
|
||||
"name": "lecture_chemin_vide",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": " "
|
||||
},
|
||||
"label": " ",
|
||||
"result": "[erreur] chemin vide"
|
||||
},
|
||||
{
|
||||
"name": "lecture_absent",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "absent.txt"
|
||||
},
|
||||
"label": "absent.txt",
|
||||
"result": "[erreur] open {{ROOT}}/ws/absent.txt: no such file or directory"
|
||||
},
|
||||
{
|
||||
"name": "lecture_dossier",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "src"
|
||||
},
|
||||
"label": "src",
|
||||
"result": "[erreur] read {{ROOT}}/ws/src: is a directory"
|
||||
},
|
||||
{
|
||||
"name": "lecture_binaire",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "bin.dat"
|
||||
},
|
||||
"label": "bin.dat",
|
||||
"result": "[binaire] bin.dat (11 octets) — pas un fichier texte"
|
||||
},
|
||||
{
|
||||
"name": "lecture_au_dela",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"offset": 99
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[erreur] offset 99 > 3 lignes"
|
||||
},
|
||||
{
|
||||
"name": "lecture_ligne_longue",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "long.txt"
|
||||
},
|
||||
"label": "long.txt",
|
||||
"result": "1: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…\n2: fin"
|
||||
},
|
||||
{
|
||||
"name": "lecture_fichier_vide",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "vide.txt"
|
||||
},
|
||||
"label": "vide.txt",
|
||||
"result": "[erreur] offset 1 > 0 lignes"
|
||||
},
|
||||
{
|
||||
"name": "lecture_absolue",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "{{ROOT}}/dehors.txt"
|
||||
},
|
||||
"label": "{{ROOT}}/dehors.txt",
|
||||
"result": "1: hors du dossier"
|
||||
},
|
||||
{
|
||||
"name": "lecture_relative_sortante",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "src/../../dehors.txt"
|
||||
},
|
||||
"label": "src/../../dehors.txt",
|
||||
"result": "1: hors du dossier"
|
||||
},
|
||||
{
|
||||
"name": "lecture_code_hors_dossier",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "../dehors.txt"
|
||||
},
|
||||
"code": true,
|
||||
"label": "../dehors.txt",
|
||||
"result": "[refusé] ../dehors.txt est hors du dossier de cette discussion. En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
},
|
||||
{
|
||||
"name": "lecture_code",
|
||||
"tool": "read",
|
||||
"args": {
|
||||
"file": "b.txt"
|
||||
},
|
||||
"code": true,
|
||||
"label": "b.txt",
|
||||
"result": "1: alpha\n2: beta"
|
||||
},
|
||||
{
|
||||
"name": "grep",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"pattern": "ligne [12]"
|
||||
},
|
||||
"label": "ligne [12]",
|
||||
"result": "a.txt:1: ligne 1\na.txt:2: ligne 2"
|
||||
},
|
||||
{
|
||||
"name": "grep_filtre",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"glob": "*.go",
|
||||
"pattern": "x"
|
||||
},
|
||||
"label": "x",
|
||||
"result": "src/lib/util.go:2: // x marque\nsrc/main.go:3: func main() { x := 1 }"
|
||||
},
|
||||
{
|
||||
"name": "grep_filtre_classe",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"glob": "[mn]*",
|
||||
"pattern": "x"
|
||||
},
|
||||
"label": "x",
|
||||
"result": "src/lib/notes.md:1: x dans les notes\nsrc/main.go:3: func main() { x := 1 }"
|
||||
},
|
||||
{
|
||||
"name": "grep_fichier",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": "b.txt",
|
||||
"pattern": "beta"
|
||||
},
|
||||
"label": "beta",
|
||||
"result": ".:2: beta"
|
||||
},
|
||||
{
|
||||
"name": "grep_sous_dossier",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": "src/lib",
|
||||
"pattern": "package"
|
||||
},
|
||||
"label": "package",
|
||||
"result": "util.go:1: package lib"
|
||||
},
|
||||
{
|
||||
"name": "grep_point",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": ".",
|
||||
"pattern": "alpha"
|
||||
},
|
||||
"label": "alpha",
|
||||
"result": "b.txt:1: alpha"
|
||||
},
|
||||
{
|
||||
"name": "grep_aucun",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"pattern": "zzz"
|
||||
},
|
||||
"label": "zzz",
|
||||
"result": "[aucun résultat]"
|
||||
},
|
||||
{
|
||||
"name": "grep_motif_vide",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"pattern": " "
|
||||
},
|
||||
"label": " ",
|
||||
"result": "[erreur] motif vide"
|
||||
},
|
||||
{
|
||||
"name": "grep_plafond",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"pattern": "aiguille"
|
||||
},
|
||||
"label": "aiguille",
|
||||
"result": "beaucoup.txt:1: aiguille\nbeaucoup.txt:2: aiguille\nbeaucoup.txt:3: aiguille\nbeaucoup.txt:4: aiguille\nbeaucoup.txt:5: aiguille\nbeaucoup.txt:6: aiguille\nbeaucoup.txt:7: aiguille\nbeaucoup.txt:8: aiguille\nbeaucoup.txt:9: aiguille\nbeaucoup.txt:10: aiguille\nbeaucoup.txt:11: aiguille\nbeaucoup.txt:12: aiguille\nbeaucoup.txt:13: aiguille\nbeaucoup.txt:14: aiguille\nbeaucoup.txt:15: aiguille\nbeaucoup.txt:16: aiguille\nbeaucoup.txt:17: aiguille\nbeaucoup.txt:18: aiguille\nbeaucoup.txt:19: aiguille\nbeaucoup.txt:20: aiguille\nbeaucoup.txt:21: aiguille\nbeaucoup.txt:22: aiguille\nbeaucoup.txt:23: aiguille\nbeaucoup.txt:24: aiguille\nbeaucoup.txt:25: aiguille\nbeaucoup.txt:26: aiguille\nbeaucoup.txt:27: aiguille\nbeaucoup.txt:28: aiguille\nbeaucoup.txt:29: aiguille\nbeaucoup.txt:30: aiguille\nbeaucoup.txt:31: aiguille\nbeaucoup.txt:32: aiguille\nbeaucoup.txt:33: aiguille\nbeaucoup.txt:34: aiguille\nbeaucoup.txt:35: aiguille\nbeaucoup.txt:36: aiguille\nbeaucoup.txt:37: aiguille\nbeaucoup.txt:38: aiguille\nbeaucoup.txt:39: aiguille\nbeaucoup.txt:40: aiguille\nbeaucoup.txt:41: aiguille\nbeaucoup.txt:42: aiguille\nbeaucoup.txt:43: aiguille\nbeaucoup.txt:44: aiguille\nbeaucoup.txt:45: aiguille\nbeaucoup.txt:46: aiguille\nbeaucoup.txt:47: aiguille\nbeaucoup.txt:48: aiguille\nbeaucoup.txt:49: aiguille\nbeaucoup.txt:50: aiguille\nbeaucoup.txt:51: aiguille\nbeaucoup.txt:52: aiguille\nbeaucoup.txt:53: aiguille\nbeaucoup.txt:54: aiguille\nbeaucoup.txt:55: aiguille\nbeaucoup.txt:56: aiguille\nbeaucoup.txt:57: aiguille\nbeaucoup.txt:58: aiguille\nbeaucoup.txt:59: aiguille\nbeaucoup.txt:60: aiguille\n… (plafond de 60 résultats atteint — affine le motif)"
|
||||
},
|
||||
{
|
||||
"name": "grep_absent",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": "nulle-part",
|
||||
"pattern": "a"
|
||||
},
|
||||
"label": "a",
|
||||
"result": "[aucun résultat]"
|
||||
},
|
||||
{
|
||||
"name": "grep_code_hors_dossier",
|
||||
"tool": "grep",
|
||||
"args": {
|
||||
"path": "{{ROOT}}",
|
||||
"pattern": "a"
|
||||
},
|
||||
"code": true,
|
||||
"label": "a",
|
||||
"result": "[refusé] {{ROOT}} est hors du dossier de cette discussion. En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
},
|
||||
{
|
||||
"name": "glob_go",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "**/*.go"
|
||||
},
|
||||
"label": "**/*.go",
|
||||
"result": "src/lib/util.go\nsrc/main.go"
|
||||
},
|
||||
{
|
||||
"name": "glob_racine",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "*.txt"
|
||||
},
|
||||
"label": "*.txt",
|
||||
"result": "crlf.txt\nlong.txt\nvide.txt\nb.txt\na.txt\nbeaucoup.txt"
|
||||
},
|
||||
{
|
||||
"name": "glob_point",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "./src/*"
|
||||
},
|
||||
"label": "./src/*",
|
||||
"result": "src/main.go"
|
||||
},
|
||||
{
|
||||
"name": "glob_aucun",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": "*.rs"
|
||||
},
|
||||
"label": "*.rs",
|
||||
"result": "[aucun fichier]"
|
||||
},
|
||||
{
|
||||
"name": "glob_vide",
|
||||
"tool": "glob",
|
||||
"args": {
|
||||
"pattern": ""
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] motif vide"
|
||||
},
|
||||
{
|
||||
"name": "question",
|
||||
"tool": "ask",
|
||||
"args": {
|
||||
"options": [
|
||||
"a",
|
||||
" ",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e"
|
||||
],
|
||||
"question": "Lequel ?"
|
||||
},
|
||||
"label": "Lequel ?",
|
||||
"result": "[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message.",
|
||||
"ask": {
|
||||
"question": "Lequel ?",
|
||||
"options": [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "question_vide",
|
||||
"tool": "ask",
|
||||
"args": {
|
||||
"question": ""
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] question vide"
|
||||
},
|
||||
{
|
||||
"name": "ecriture_neuve",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "un\ndeux\n",
|
||||
"file": "neuf/n.txt"
|
||||
},
|
||||
"label": "neuf/n.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/neuf/n.txt créé (8 octets)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "+",
|
||||
"text": "un"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "deux"
|
||||
}
|
||||
],
|
||||
"added": 2
|
||||
},
|
||||
{
|
||||
"name": "ecriture_existant",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "remplacé\n",
|
||||
"file": "b.txt"
|
||||
},
|
||||
"label": "b.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/b.txt réécrit (10 octets)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "+",
|
||||
"text": "remplacé"
|
||||
}
|
||||
],
|
||||
"added": 1
|
||||
},
|
||||
{
|
||||
"name": "ecriture_memoire",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "x\n",
|
||||
"file": "{{MEM}}/x.md"
|
||||
},
|
||||
"label": "{{ROOT}}/memory/generale/x.md",
|
||||
"result": "[refusé] pas d'accès direct au dossier {{ROOT}}/memory/generale. Utilise les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete).",
|
||||
"diff": [
|
||||
{
|
||||
"op": "+",
|
||||
"text": "x"
|
||||
}
|
||||
],
|
||||
"added": 1
|
||||
},
|
||||
{
|
||||
"name": "ecriture_chemin_vide",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "x",
|
||||
"file": ""
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] chemin vide"
|
||||
},
|
||||
{
|
||||
"name": "ecriture_code_non_lu",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "x\n",
|
||||
"file": "src/lib/notes.md"
|
||||
},
|
||||
"code": true,
|
||||
"label": "src/lib/notes.md",
|
||||
"result": "[refusé] {{ROOT}}/ws/src/lib/notes.md existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
|
||||
},
|
||||
{
|
||||
"name": "ecriture_code_hors_dossier",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "x\n",
|
||||
"file": "../evade.txt"
|
||||
},
|
||||
"code": true,
|
||||
"label": "../evade.txt",
|
||||
"result": "[refusé] ../evade.txt est hors du dossier de cette discussion. En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
},
|
||||
{
|
||||
"name": "ecriture_code_neuve",
|
||||
"tool": "write",
|
||||
"args": {
|
||||
"content": "c\n",
|
||||
"file": "code.txt"
|
||||
},
|
||||
"code": true,
|
||||
"label": "code.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/code.txt créé (2 octets)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "+",
|
||||
"text": "c"
|
||||
}
|
||||
],
|
||||
"added": 1
|
||||
},
|
||||
{
|
||||
"name": "edition",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "ligne deux",
|
||||
"old": "ligne 2"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/a.txt modifié (1 remplacement)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "-",
|
||||
"text": "ligne 2"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "ligne deux"
|
||||
}
|
||||
],
|
||||
"added": 1,
|
||||
"removed": 1
|
||||
},
|
||||
{
|
||||
"name": "edition_deja_faite",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "ligne deux",
|
||||
"old": "ligne 2"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[ok] déjà à jour — le fichier contient déjà cette modification",
|
||||
"diff": [
|
||||
{
|
||||
"op": "-",
|
||||
"text": "ligne 2"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "ligne deux"
|
||||
}
|
||||
],
|
||||
"added": 1,
|
||||
"removed": 1
|
||||
},
|
||||
{
|
||||
"name": "edition_introuvable",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "y",
|
||||
"old": "zzz"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[erreur] old introuvable dans le fichier"
|
||||
},
|
||||
{
|
||||
"name": "edition_ambigue",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "l",
|
||||
"old": "ligne"
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[erreur] old apparaît 3 fois — ajoute du contexte pour le rendre unique"
|
||||
},
|
||||
{
|
||||
"name": "edition_crlf",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "crlf.txt",
|
||||
"new": "2\n3",
|
||||
"old": "deux\ntrois"
|
||||
},
|
||||
"label": "crlf.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/crlf.txt modifié (1 remplacement)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "-",
|
||||
"text": "deux"
|
||||
},
|
||||
{
|
||||
"op": "-",
|
||||
"text": "trois"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "2"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "3"
|
||||
}
|
||||
],
|
||||
"added": 2,
|
||||
"removed": 2
|
||||
},
|
||||
{
|
||||
"name": "edition_old_vide",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "a.txt",
|
||||
"new": "y",
|
||||
"old": ""
|
||||
},
|
||||
"label": "a.txt",
|
||||
"result": "[erreur] old vide"
|
||||
},
|
||||
{
|
||||
"name": "edition_fichier_absent",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "absent.txt",
|
||||
"new": "b",
|
||||
"old": "a"
|
||||
},
|
||||
"label": "absent.txt",
|
||||
"result": "[erreur] open {{ROOT}}/ws/absent.txt: no such file or directory"
|
||||
},
|
||||
{
|
||||
"name": "edition_code_non_lu",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "src/main.go",
|
||||
"new": "x := 2",
|
||||
"old": "x := 1"
|
||||
},
|
||||
"code": true,
|
||||
"label": "src/main.go",
|
||||
"result": "[refusé] {{ROOT}}/ws/src/main.go existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
|
||||
},
|
||||
{
|
||||
"name": "edition_code_apres_lecture",
|
||||
"tool": "edit",
|
||||
"args": {
|
||||
"file": "b.txt",
|
||||
"new": "rem",
|
||||
"old": "remplacé"
|
||||
},
|
||||
"code": true,
|
||||
"label": "b.txt",
|
||||
"result": "[ok] {{ROOT}}/ws/b.txt modifié (1 remplacement)",
|
||||
"diff": [
|
||||
{
|
||||
"op": "-",
|
||||
"text": "remplacé"
|
||||
},
|
||||
{
|
||||
"op": "+",
|
||||
"text": "rem"
|
||||
}
|
||||
],
|
||||
"added": 1,
|
||||
"removed": 1
|
||||
},
|
||||
{
|
||||
"name": "shell",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "echo bonjour"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "echo bonjour",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\nbonjour\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_sortie_et_erreurs",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "echo out; echo err >&2; exit 3"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "echo out; echo err >&2; exit 3",
|
||||
"result": "exit: 3 · {{DUR}}\n\nstdout:\nout\n\n\nstderr:\nerr\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_tail",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "printf 'a\\nb\\nc\\n' | tail -2"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "printf 'a\\nb\\nc\\n' | tail -2",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\nb\nc"
|
||||
},
|
||||
{
|
||||
"name": "shell_ansi",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "printf '\\033[31mrouge\\033[0m\\n'"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "printf '\\033[31mrouge\\033[0m\\n'",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\nrouge\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_dossier",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "ls src"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "ls src",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\nlib\nmain.go\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_longue_sortie",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "seq 1 3000"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "seq 1 3000",
|
||||
"result": "exit: 0 · {{DUR}}\n\nstdout:\n[sortie tronquée : 13893 caractères au total, seuls les 8000 derniers sont gardés]\n1401\n1402\n1403\n1404\n1405\n1406\n1407\n1408\n1409\n1410\n1411\n1412\n1413\n1414\n1415\n1416\n1417\n1418\n1419\n1420\n1421\n1422\n1423\n1424\n1425\n1426\n1427\n1428\n1429\n1430\n1431\n1432\n1433\n1434\n1435\n1436\n1437\n1438\n1439\n1440\n1441\n1442\n1443\n1444\n1445\n1446\n1447\n1448\n1449\n1450\n1451\n1452\n1453\n1454\n1455\n1456\n1457\n1458\n1459\n1460\n1461\n1462\n1463\n1464\n1465\n1466\n1467\n1468\n1469\n1470\n1471\n1472\n1473\n1474\n1475\n1476\n1477\n1478\n1479\n1480\n1481\n1482\n1483\n1484\n1485\n1486\n1487\n1488\n1489\n1490\n1491\n1492\n1493\n1494\n1495\n1496\n1497\n1498\n1499\n1500\n1501\n1502\n1503\n1504\n1505\n1506\n1507\n1508\n1509\n1510\n1511\n1512\n1513\n1514\n1515\n1516\n1517\n1518\n1519\n1520\n1521\n1522\n1523\n1524\n1525\n1526\n1527\n1528\n1529\n1530\n1531\n1532\n1533\n1534\n1535\n1536\n1537\n1538\n1539\n1540\n1541\n1542\n1543\n1544\n1545\n1546\n1547\n1548\n1549\n1550\n1551\n1552\n1553\n1554\n1555\n1556\n1557\n1558\n1559\n1560\n1561\n1562\n1563\n1564\n1565\n1566\n1567\n1568\n1569\n1570\n1571\n1572\n1573\n1574\n1575\n1576\n1577\n1578\n1579\n1580\n1581\n1582\n1583\n1584\n1585\n1586\n1587\n1588\n1589\n1590\n1591\n1592\n1593\n1594\n1595\n1596\n1597\n1598\n1599\n1600\n1601\n1602\n1603\n1604\n1605\n1606\n1607\n1608\n1609\n1610\n1611\n1612\n1613\n1614\n1615\n1616\n1617\n1618\n1619\n1620\n1621\n1622\n1623\n1624\n1625\n1626\n1627\n1628\n1629\n1630\n1631\n1632\n1633\n1634\n1635\n1636\n1637\n1638\n1639\n1640\n1641\n1642\n1643\n1644\n1645\n1646\n1647\n1648\n1649\n1650\n1651\n1652\n1653\n1654\n1655\n1656\n1657\n1658\n1659\n1660\n1661\n1662\n1663\n1664\n1665\n1666\n1667\n1668\n1669\n1670\n1671\n1672\n1673\n1674\n1675\n1676\n1677\n1678\n1679\n1680\n1681\n1682\n1683\n1684\n1685\n1686\n1687\n1688\n1689\n1690\n1691\n1692\n1693\n1694\n1695\n1696\n1697\n1698\n1699\n1700\n1701\n1702\n1703\n1704\n1705\n1706\n1707\n1708\n1709\n1710\n1711\n1712\n1713\n1714\n1715\n1716\n1717\n1718\n1719\n1720\n1721\n1722\n1723\n1724\n1725\n1726\n1727\n1728\n1729\n1730\n1731\n1732\n1733\n1734\n1735\n1736\n1737\n1738\n1739\n1740\n1741\n1742\n1743\n1744\n1745\n1746\n1747\n1748\n1749\n1750\n1751\n1752\n1753\n1754\n1755\n1756\n1757\n1758\n1759\n1760\n1761\n1762\n1763\n1764\n1765\n1766\n1767\n1768\n1769\n1770\n1771\n1772\n1773\n1774\n1775\n1776\n1777\n1778\n1779\n1780\n1781\n1782\n1783\n1784\n1785\n1786\n1787\n1788\n1789\n1790\n1791\n1792\n1793\n1794\n1795\n1796\n1797\n1798\n1799\n1800\n1801\n1802\n1803\n1804\n1805\n1806\n1807\n1808\n1809\n1810\n1811\n1812\n1813\n1814\n1815\n1816\n1817\n1818\n1819\n1820\n1821\n1822\n1823\n1824\n1825\n1826\n1827\n1828\n1829\n1830\n1831\n1832\n1833\n1834\n1835\n1836\n1837\n1838\n1839\n1840\n1841\n1842\n1843\n1844\n1845\n1846\n1847\n1848\n1849\n1850\n1851\n1852\n1853\n1854\n1855\n1856\n1857\n1858\n1859\n1860\n1861\n1862\n1863\n1864\n1865\n1866\n1867\n1868\n1869\n1870\n1871\n1872\n1873\n1874\n1875\n1876\n1877\n1878\n1879\n1880\n1881\n1882\n1883\n1884\n1885\n1886\n1887\n1888\n1889\n1890\n1891\n1892\n1893\n1894\n1895\n1896\n1897\n1898\n1899\n1900\n1901\n1902\n1903\n1904\n1905\n1906\n1907\n1908\n1909\n1910\n1911\n1912\n1913\n1914\n1915\n1916\n1917\n1918\n1919\n1920\n1921\n1922\n1923\n1924\n1925\n1926\n1927\n1928\n1929\n1930\n1931\n1932\n1933\n1934\n1935\n1936\n1937\n1938\n1939\n1940\n1941\n1942\n1943\n1944\n1945\n1946\n1947\n1948\n1949\n1950\n1951\n1952\n1953\n1954\n1955\n1956\n1957\n1958\n1959\n1960\n1961\n1962\n1963\n1964\n1965\n1966\n1967\n1968\n1969\n1970\n1971\n1972\n1973\n1974\n1975\n1976\n1977\n1978\n1979\n1980\n1981\n1982\n1983\n1984\n1985\n1986\n1987\n1988\n1989\n1990\n1991\n1992\n1993\n1994\n1995\n1996\n1997\n1998\n1999\n2000\n2001\n2002\n2003\n2004\n2005\n2006\n2007\n2008\n2009\n2010\n2011\n2012\n2013\n2014\n2015\n2016\n2017\n2018\n2019\n2020\n2021\n2022\n2023\n2024\n2025\n2026\n2027\n2028\n2029\n2030\n2031\n2032\n2033\n2034\n2035\n2036\n2037\n2038\n2039\n2040\n2041\n2042\n2043\n2044\n2045\n2046\n2047\n2048\n2049\n2050\n2051\n2052\n2053\n2054\n2055\n2056\n2057\n2058\n2059\n2060\n2061\n2062\n2063\n2064\n2065\n2066\n2067\n2068\n2069\n2070\n2071\n2072\n2073\n2074\n2075\n2076\n2077\n2078\n2079\n2080\n2081\n2082\n2083\n2084\n2085\n2086\n2087\n2088\n2089\n2090\n2091\n2092\n2093\n2094\n2095\n2096\n2097\n2098\n2099\n2100\n2101\n2102\n2103\n2104\n2105\n2106\n2107\n2108\n2109\n2110\n2111\n2112\n2113\n2114\n2115\n2116\n2117\n2118\n2119\n2120\n2121\n2122\n2123\n2124\n2125\n2126\n2127\n2128\n2129\n2130\n2131\n2132\n2133\n2134\n2135\n2136\n2137\n2138\n2139\n2140\n2141\n2142\n2143\n2144\n2145\n2146\n2147\n2148\n2149\n2150\n2151\n2152\n2153\n2154\n2155\n2156\n2157\n2158\n2159\n2160\n2161\n2162\n2163\n2164\n2165\n2166\n2167\n2168\n2169\n2170\n2171\n2172\n2173\n2174\n2175\n2176\n2177\n2178\n2179\n2180\n2181\n2182\n2183\n2184\n2185\n2186\n2187\n2188\n2189\n2190\n2191\n2192\n2193\n2194\n2195\n2196\n2197\n2198\n2199\n2200\n2201\n2202\n2203\n2204\n2205\n2206\n2207\n2208\n2209\n2210\n2211Line truncated
|
||||
},
|
||||
{
|
||||
"name": "shell_delai",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "echo avant; sleep 3",
|
||||
"timeout": 1
|
||||
},
|
||||
"unix": true,
|
||||
"label": "echo avant; sleep 3",
|
||||
"result": "[timeout après 1s — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]\n\nstdout (partiel):\navant\n"
|
||||
},
|
||||
{
|
||||
"name": "shell_dangereux",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "rm -rf /"
|
||||
},
|
||||
"label": "rm -rf /",
|
||||
"result": "[refusé] Commande bloquée par la politique de sécurité : efface la racine du disque ou le home. Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal."
|
||||
},
|
||||
{
|
||||
"name": "shell_memoire",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "cat {{MEM}}/x.md"
|
||||
},
|
||||
"unix": true,
|
||||
"label": "cat {{ROOT}}/memory/generale/x.md",
|
||||
"result": "[refusé] pas d'accès direct au dossier {{ROOT}}/memory/generale via le shell. Utilise les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)."
|
||||
},
|
||||
{
|
||||
"name": "shell_code_arriere_plan",
|
||||
"tool": "bash",
|
||||
"args": {
|
||||
"command": "sleep 1 &"
|
||||
},
|
||||
"code": true,
|
||||
"unix": true,
|
||||
"label": "sleep 1 &",
|
||||
"result": "[refusé] commande terminée par « & » : lance-la avec bash_bg (sortie consultable avec bash_tail), pas en arrière-plan dans bash."
|
||||
}
|
||||
],
|
||||
"after": {
|
||||
"dehors.txt": "hors du dossier\n",
|
||||
"ws/a.txt": "ligne 1\nligne deux\nligne 3\n",
|
||||
"ws/b.txt": "rem\n",
|
||||
"ws/beaucoup.txt": "aiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\naiguille\n",
|
||||
"ws/bin.dat": "PK\u0000\u0001binaire",
|
||||
"ws/code.txt": "c\n",
|
||||
"ws/crlf.txt": "un\r\n2\r\n3\r\n",
|
||||
"ws/long.txt": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\nfin\n",
|
||||
"ws/neuf/n.txt": "un\ndeux\n",
|
||||
"ws/node_modules/dep/index.js": "ligne cachée\n",
|
||||
"ws/src/lib/notes.md": "x dans les notes\n",
|
||||
"ws/src/lib/util.go": "package lib\n// x marque\n",
|
||||
"ws/src/main.go": "package main\n\nfunc main() { x := 1 }\n",
|
||||
"ws/vide.txt": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Parité des outils fichiers et du shell avec le Go (lot 7).
|
||||
//!
|
||||
//! `tests/parity_tools/outils.json` : journal produit par `execTool`
|
||||
//! (`internal/loki/parity_tools_test.go`, régénéré par
|
||||
//! `LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils`). La
|
||||
//! même arborescence est recréée ici et les mêmes appels passent, dans le même
|
||||
//! ordre, par `tools::exec_local` : libellés, résultats, diffs, questions et
|
||||
//! arborescence finale doivent être ceux du Go. L'état partagé (suivi « lu
|
||||
//! avant d'écrire ») est tenu par le Go en service ; ici un faux qui en
|
||||
//! reprend les règles et les messages.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use loki_rs::chat::stream::tool_call_label;
|
||||
use loki_rs::chat::types::{AskEvent, DiffLine};
|
||||
use loki_rs::tools::{ToolCtx, ToolState, exec_local};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FileFx {
|
||||
path: String,
|
||||
content: String,
|
||||
age: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CaseFx {
|
||||
name: String,
|
||||
tool: String,
|
||||
args: Map<String, Value>,
|
||||
#[serde(default)]
|
||||
code: bool,
|
||||
#[serde(default)]
|
||||
unix: bool,
|
||||
label: String,
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
diff: Vec<DiffLine>,
|
||||
#[serde(default)]
|
||||
added: i64,
|
||||
#[serde(default)]
|
||||
removed: i64,
|
||||
#[serde(default)]
|
||||
ask: Option<AskEvent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Fixture {
|
||||
cwd: String,
|
||||
memory_dir: String,
|
||||
files: Vec<FileFx>,
|
||||
cases: Vec<CaseFx>,
|
||||
after: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Le suivi « lu avant d'écrire » du Go (`code_tracker.go`), en mémoire.
|
||||
struct FakeState {
|
||||
ctx: ToolCtx,
|
||||
reads: Mutex<HashMap<PathBuf, SystemTime>>,
|
||||
}
|
||||
|
||||
impl ToolState for FakeState {
|
||||
async fn context(&self) -> Result<ToolCtx, String> {
|
||||
Ok(self.ctx.clone())
|
||||
}
|
||||
|
||||
async fn note_read(&self, path: &Path) {
|
||||
self.reads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.to_path_buf(), SystemTime::now());
|
||||
}
|
||||
|
||||
async fn check(&self, path: &Path, _for_write: bool) -> Option<String> {
|
||||
let mtime = std::fs::metadata(path).ok()?.modified().ok()?;
|
||||
let p = path.display();
|
||||
match self.reads.lock().unwrap().get(path) {
|
||||
None => Some(format!(
|
||||
"[refusé] {p} existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
|
||||
)),
|
||||
Some(at) if mtime > *at => Some(format!(
|
||||
"[refusé] {p} a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier."
|
||||
)),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wrote(&self, path: &Path) -> String {
|
||||
self.note_read(path).await;
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn subst(v: &Value, root: &str, mem: &str) -> Value {
|
||||
match v {
|
||||
Value::String(s) => Value::String(s.replace("{{MEM}}", mem).replace("{{ROOT}}", root)),
|
||||
Value::Object(m) => Value::Object(
|
||||
m.iter()
|
||||
.map(|(k, e)| (k.clone(), subst(e, root, mem)))
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(a) => Value::Array(a.iter().map(|e| subst(e, root, mem)).collect()),
|
||||
_ => v.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn norm(s: &str, root: &str) -> String {
|
||||
static DUR: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
|
||||
Regex::new(r"(exit: -?\d+) · [0-9.]+[a-zµ]+(?:[0-9.]+[a-zµ]+)*").unwrap()
|
||||
});
|
||||
DUR.replace_all(&s.replace(root, "{{ROOT}}"), "$1 · {{DUR}}")
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn tree(root: &Path) -> BTreeMap<String, String> {
|
||||
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, String>) {
|
||||
for e in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
walk(root, &p, out);
|
||||
continue;
|
||||
}
|
||||
let rel = p
|
||||
.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if rel.starts_with("ws/") || rel == "dehors.txt" || rel == "evade.txt" {
|
||||
out.insert(
|
||||
rel,
|
||||
String::from_utf8_lossy(&std::fs::read(&p).unwrap()).into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
walk(root, root, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outils_comme_le_go() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/parity_tools/outils.json");
|
||||
let fx: Fixture = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = std::fs::canonicalize(tmp.path()).unwrap();
|
||||
let root_s = root.display().to_string();
|
||||
let now = SystemTime::now();
|
||||
for f in &fx.files {
|
||||
let p = root.join(&f.path);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, &f.content).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&p)
|
||||
.unwrap()
|
||||
.set_modified(now - Duration::from_secs(f.age))
|
||||
.unwrap();
|
||||
}
|
||||
let mem = root.join(&fx.memory_dir);
|
||||
let st = FakeState {
|
||||
ctx: ToolCtx {
|
||||
cwd: root.join(&fx.cwd),
|
||||
memory_dir: mem.clone(),
|
||||
target: String::new(),
|
||||
code: false,
|
||||
},
|
||||
reads: Mutex::new(HashMap::new()),
|
||||
};
|
||||
let mem_s = mem.display().to_string();
|
||||
let mut diffs = Vec::new();
|
||||
for c in &fx.cases {
|
||||
if c.unix && cfg!(windows) {
|
||||
continue;
|
||||
}
|
||||
let args = match subst(&Value::Object(c.args.clone()), &root_s, &mem_s) {
|
||||
Value::Object(m) => m,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let label = tool_call_label(&c.tool, &args);
|
||||
let out = exec_local(&st, c.code, &c.tool, &args, &label)
|
||||
.await
|
||||
.unwrap_or_else(|| panic!("{} : non exécuté ici", c.name));
|
||||
let got = json!({
|
||||
"label": norm(&label, &root_s),
|
||||
"result": norm(&out.result, &root_s),
|
||||
"diff": out.diff,
|
||||
"added": out.added,
|
||||
"removed": out.removed,
|
||||
"ask": out.ask,
|
||||
});
|
||||
let want = json!({
|
||||
"label": c.label,
|
||||
"result": c.result,
|
||||
"diff": c.diff,
|
||||
"added": c.added,
|
||||
"removed": c.removed,
|
||||
"ask": c.ask,
|
||||
});
|
||||
if got != want {
|
||||
diffs.push(format!("{} :\n obtenu {got}\n attendu {want}", c.name));
|
||||
}
|
||||
}
|
||||
let after = tree(&root);
|
||||
if after != fx.after {
|
||||
diffs.push(format!(
|
||||
"arborescence finale :\n obtenu {after:?}\n attendu {:?}",
|
||||
fx.after
|
||||
));
|
||||
}
|
||||
assert!(
|
||||
diffs.is_empty(),
|
||||
"{} écart(s) avec le Go :\n\n{}",
|
||||
diffs.len(),
|
||||
diffs.join("\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// Outils non portés, ou cible distante : c'est au Go de les exécuter.
|
||||
#[tokio::test]
|
||||
async fn le_reste_va_au_go() {
|
||||
let st = FakeState {
|
||||
ctx: ToolCtx {
|
||||
cwd: PathBuf::from("/nulle-part"),
|
||||
memory_dir: PathBuf::from("/nulle-part/memory"),
|
||||
target: "poste".into(),
|
||||
code: false,
|
||||
},
|
||||
reads: Mutex::new(HashMap::new()),
|
||||
};
|
||||
let a = |v: Value| v.as_object().unwrap().clone();
|
||||
for (name, args) in [
|
||||
("mem_read", json!({"file":"x"})),
|
||||
("web_search", json!({"query":"x"})),
|
||||
("write", json!({"file":"a","content":"x"})),
|
||||
("edit", json!({"file":"a","old":"x","new":"y"})),
|
||||
("bash", json!({"command":"ls"})),
|
||||
] {
|
||||
let args = a(args);
|
||||
let label = tool_call_label(name, &args);
|
||||
assert!(
|
||||
exec_local(&st, false, name, &args, &label).await.is_none(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
// Une commande catastrophique est refusée ici, cible distante comprise.
|
||||
let args = a(json!({"command":"rm -rf /"}));
|
||||
let out = exec_local(&st, false, "bash", &args, "rm -rf /")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.result.starts_with("[refusé] Commande bloquée"));
|
||||
// read/grep/glob lisent toujours cette machine.
|
||||
let args = a(json!({"pattern":"*"}));
|
||||
assert!(exec_local(&st, false, "glob", &args, "*").await.is_some());
|
||||
}
|
||||
Reference in new issue
Block a user