mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 06:58:14 +02:00
Lot 40 : outils mémoire de l'agent exécutés par la façade
Quand la façade tient la mémoire (LOKI_MEM=rust), les outils mem_search, mem_read, mem_add, mem_edit et tracker de ses tours s'exécutent chez elle (src/memory/tools.rs, port de chat_memory.go et de l'outil de tracker.go) : mêmes résultats et diffs, mêmes lectures et écritures que les routes de la mémoire (scellées selon le chiffrement, .bak, index tenu par le code, seed au nom du projet). Recherche classée par couverture puis pertinence, extrait coupé à l'octet comme le Go ; trackers vus niveau par niveau. ChatShared porte la même mémoire que les routes ; tracker_add prend l'heure en paramètre (tracker_add_at). Côté Go, l'heure des trackers passe par trackerNow, que le journal fixe. Parité : journal de 57 appels tiré du Go (parity_mem_tools_test.go → tests/parity_mem/outils.json : résultats, diffs, fichiers de la mémoire et trackers), rejoué par tests/memory.rs ; un tour de la façade qui crée une page (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
+3578
-14
No files matched your search
@@ -970,6 +970,7 @@ pub async fn start_with(
|
||||
shared.patterns = loki_rs::chat::generate::retry_patterns(&store).await;
|
||||
shared.mcp = opts.mcp.clone();
|
||||
shared.tasks = opts.tasks.clone();
|
||||
shared.memory = opts.mem.clone();
|
||||
Some(ChatService::new(Arc::new(s), Arc::new(shared)))
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -670,6 +670,7 @@ async fn tour_complet_par_le_go() {
|
||||
perf: Arc::new(loki_rs::perfring::PerfRing::new(Default::default())),
|
||||
mcp: None,
|
||||
tasks: None,
|
||||
memory: None,
|
||||
go_version: Default::default(),
|
||||
});
|
||||
let cfg = std::collections::HashMap::new();
|
||||
@@ -2708,3 +2709,40 @@ async fn anneau_de_la_facade_pour_le_go() {
|
||||
assert_eq!(code, 405);
|
||||
drop(go);
|
||||
}
|
||||
|
||||
/// Lot 40 : un outil mémoire appelé par un tour de la façade s'exécute ici
|
||||
/// quand elle tient la mémoire — la page rangée dans le projet actif, l'index
|
||||
/// tenu, le diff montré, rien par le Go.
|
||||
#[tokio::test]
|
||||
async fn outil_memoire_par_la_facade() {
|
||||
let call = sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"m1","type":"function","function":{"name":"mem_add","arguments":"{\"file\":\"chat\",\"content\":\"# Chat\\nIl s'appelle Pixel.\"}"}}]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
]);
|
||||
let (url, seen) = fake_engine(vec![call, answer("noté")]).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mem = Arc::new(loki_rs::memory::Memory::new(dir.path().to_path_buf()));
|
||||
let (svc, go, _stop, _t) = service_with(Some(&url), |s| s.memory = Some(mem.clone())).await;
|
||||
*go.crypt_mode.lock().unwrap() = fakego::CryptMode::Plain;
|
||||
svc.send(serde_json::json!({"message": "retiens le nom du chat", "cid": "m"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log);
|
||||
let page = dir.path().join("memory/generale/chat.md");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&page).unwrap(),
|
||||
"# Chat\nIl s'appelle Pixel.\n"
|
||||
);
|
||||
let index = std::fs::read_to_string(dir.path().join("memory/generale/MEMORY.md")).unwrap();
|
||||
assert!(index.ends_with("- [Chat](chat.md)\n"), "{index}");
|
||||
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!("[ok] page 'chat' créée"));
|
||||
}
|
||||
@@ -392,3 +392,149 @@ async fn coffre_et_projets_restent_au_go() {
|
||||
assert_eq!(&b[..], format!("go:{p}").as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Lot 40 : les outils mémoire de l'agent (mem_search, mem_read, mem_add,
|
||||
/// mem_edit, tracker), rejoués contre le journal du Go
|
||||
/// (`parity_mem_tools_test.go` → `parity_mem/outils.json`) : résultat,
|
||||
/// diff, fichiers de la mémoire et trackers après chaque appel.
|
||||
#[tokio::test]
|
||||
async fn outils_memoire_comme_le_go() {
|
||||
use loki_rs::memory::tools::{MemEnv, exec, is_tool};
|
||||
let fx: Value = serde_json::from_str(include_str!("parity_mem/outils.json")).expect("journal");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().to_path_buf();
|
||||
let (_f, go, _stop) = fakego::start(home.clone()).await;
|
||||
*go.crypt_mode.lock().unwrap() = CryptMode::Plain;
|
||||
let backend = go.backend.lock().unwrap().unwrap();
|
||||
let store = loki_rs::store::Store::new(backend, fakego::JETON.into());
|
||||
let mem = Memory::with_tz(home.clone(), Tz::Fixed(0));
|
||||
let dir = home.join("memory").join("generale");
|
||||
let mut ids = TrackerIds::default();
|
||||
let mut now = 0i64;
|
||||
for st in fx.as_array().unwrap() {
|
||||
let name = st["name"].as_str().unwrap();
|
||||
if let Some(n) = st["now"].as_str() {
|
||||
now = rfc3339_ms(n);
|
||||
}
|
||||
let tool = st["tool"].as_str().unwrap();
|
||||
assert!(is_tool(tool), "{name}");
|
||||
// Les identifiants de points déjà vus : {{Tn}} → valeur.
|
||||
let raw = ids.unmask(&st["args"].to_string());
|
||||
let args: serde_json::Map<String, Value> = serde_json::from_str(&raw).unwrap();
|
||||
let label = st["label"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
loki_rs::chat::stream::tool_call_label(tool, &args),
|
||||
label,
|
||||
"{name} : libellé"
|
||||
);
|
||||
let env = MemEnv {
|
||||
store: &store,
|
||||
mem: &mem,
|
||||
now_ms: now,
|
||||
};
|
||||
let out = exec(&env, tool, &args, label).await.expect("outil mémoire");
|
||||
assert_eq!(ids.mask(&out.result), st["out"].as_str().unwrap(), "{name}");
|
||||
let diff = serde_json::to_value(&out.diff).unwrap();
|
||||
if st["diff"].is_null() {
|
||||
assert!(out.diff.is_empty(), "{name} : diff {diff}");
|
||||
} else {
|
||||
assert_eq!(diff, st["diff"], "{name} : diff");
|
||||
}
|
||||
assert_eq!(
|
||||
(out.added, out.removed),
|
||||
(
|
||||
st["added"].as_i64().unwrap(),
|
||||
st["removed"].as_i64().unwrap()
|
||||
),
|
||||
"{name}"
|
||||
);
|
||||
let mut files = serde_json::Map::new();
|
||||
for (rel, content) in walk(&dir) {
|
||||
files.insert(rel, Value::String(content));
|
||||
}
|
||||
assert_eq!(Value::Object(files), st["files"], "{name} : fichiers");
|
||||
let mut trackers = serde_json::Map::new();
|
||||
let all = store.all("tracker").await.unwrap_or_default();
|
||||
for k in all.keys() {
|
||||
let b = store.get_open("tracker", k).await.unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(&ids.mask(&String::from_utf8_lossy(&b))).unwrap();
|
||||
trackers.insert(k.clone(), v);
|
||||
}
|
||||
assert_eq!(Value::Object(trackers), st["trackers"], "{name} : trackers");
|
||||
}
|
||||
assert!(
|
||||
exec(
|
||||
&MemEnv {
|
||||
store: &store,
|
||||
mem: &mem,
|
||||
now_ms: now
|
||||
},
|
||||
"bash",
|
||||
&serde_json::Map::new(),
|
||||
""
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// Identifiants de points de trackers → {{T1}}, {{T2}}… dans l'ordre où
|
||||
/// ils apparaissent (même règle que le journal du Go).
|
||||
#[derive(Default)]
|
||||
struct TrackerIds {
|
||||
ids: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl TrackerIds {
|
||||
fn mask(&mut self, s: &str) -> String {
|
||||
let re = regex::Regex::new(r"\b[0-9]{18,20}\b").unwrap();
|
||||
re.replace_all(s, |c: ®ex::Captures| {
|
||||
let id = c[0].to_string();
|
||||
if let Some((_, p)) = self.ids.iter().find(|(i, _)| *i == id) {
|
||||
return p.clone();
|
||||
}
|
||||
let p = format!("{{{{T{}}}}}", self.ids.len() + 1);
|
||||
self.ids.push((id, p.clone()));
|
||||
p
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn unmask(&self, s: &str) -> String {
|
||||
let mut s = s.to_string();
|
||||
// Les plus longs d'abord : {{T10}} avant {{T1}}.
|
||||
let mut ids: Vec<_> = self.ids.iter().collect();
|
||||
ids.sort_by_key(|(_, p)| std::cmp::Reverse(p.len()));
|
||||
for (id, p) in ids {
|
||||
s = s.replace(p, id);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn rfc3339_ms(v: &str) -> i64 {
|
||||
let p: Vec<i64> = v[..10].split('-').map(|x| x.parse().unwrap()).collect();
|
||||
let h: Vec<i64> = v[11..19].split(':').map(|x| x.parse().unwrap()).collect();
|
||||
loki_rs::tz::Location::utc().date(p[0], p[1], p[2], h[0], h[1], h[2]) * 1000
|
||||
}
|
||||
|
||||
/// Les fichiers d'un dossier, chemin relatif → contenu.
|
||||
fn walk(dir: &Path) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else {
|
||||
continue;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else {
|
||||
let rel = p.strip_prefix(dir).unwrap().to_string_lossy().into_owned();
|
||||
out.insert(rel, std::fs::read_to_string(&p).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user