From f1f58b9996f8eb58871d44fdd41c2d863874f253 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 28 Aug 2026 12:52:58 +0700 Subject: [PATCH] fix(agent): recall search + memory_dir fallback + bersihkan dead llm_client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hasil audit round 4 (workflow/hive_mind + memory + semantic_search). - fix(memory): recall.search selama ini TIDAK pernah dipakai — tool mengiklankan keyword search di skema tapi run() cuma list semua nama. Kini search benar-benar memfilter (cocok di name/description/content, case-insensitive), + output 'No memories match' bila kosong. - fix(memory): ToolCtxBuilder tidak punya setter memory_dir dan tak ada call-site yang mengisinya — remember/recall/forget memakai PathBuf kosong dan menulis memory ke CWD (bukan lokasi persisten). Tambah setter memory_dir + worktrees_dir, dan helper resolve_memory_dir() yang fallback ke Store::new().memory_dir bila ctx.memory_dir kosong; dipakai di ketiga tool memory. - refactor(workflow): hapus LlmClient dummy di WorkflowRun (dibuat dengan API key kosong + model default + base_url default lalu tak pernah dipakai — execute_workflow menerimanya sebagai _llm_client). Kini execute_workflow tak ambil parameter tak terpakai; LLM asli tetap lewat execute_primitive yang resolve kredensial dengan benar. - test: +2 (recall search memfilter; resolve_memory_dir fallback/eksplisit). Catatan audit yang dilaporkan (belum difix): synth_consensus hanya menggabungkan output (label Consensus menyesatkan, bukan sintesis LLM), dan semantic_search memegang Mutex index global saat full rebuild (bottleneck saat paralel) + index tidak workspace-aware. PENTING (infra): disk root 100% saat kerja. Saya bebaskan ~4.6G dari /tmp + cache aman (sekai*, verify-z, bun/npm cache). target/debug di repo = 38G — rampah, perlu cargo clean + rebuild (jangan dibiarkan). --- Cargo.lock | 22 +-- apps/infrastructure/src/tools/context.rs | 8 ++ .../infrastructure/src/tools/memory/forget.rs | 3 +- apps/infrastructure/src/tools/memory/mod.rs | 16 +++ .../infrastructure/src/tools/memory/recall.rs | 131 ++++++++++++++++-- .../src/tools/memory/remember.rs | 3 +- apps/infrastructure/src/tools/workflow.rs | 9 +- .../src/workflow/engine/execution.rs | 9 +- 8 files changed, 163 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ddd072..50cb3e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4862,7 +4862,7 @@ dependencies = [ [[package]] name = "zesdex-api" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "argon2", @@ -4885,7 +4885,7 @@ dependencies = [ [[package]] name = "zesdex-application" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "base64", @@ -4903,7 +4903,7 @@ dependencies = [ [[package]] name = "zesdex-bootstrap" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "chrono", @@ -4920,7 +4920,7 @@ dependencies = [ [[package]] name = "zesdex-daemon" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "base64", @@ -4944,7 +4944,7 @@ dependencies = [ [[package]] name = "zesdex-domain" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "base64", @@ -4960,7 +4960,7 @@ dependencies = [ [[package]] name = "zesdex-gateway" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "axum", @@ -4987,7 +4987,7 @@ dependencies = [ [[package]] name = "zesdex-grpc" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "axum", @@ -5004,7 +5004,7 @@ dependencies = [ [[package]] name = "zesdex-infrastructure" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "argon2", @@ -5052,7 +5052,7 @@ dependencies = [ [[package]] name = "zesdex-tui" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "base64", @@ -5078,7 +5078,7 @@ dependencies = [ [[package]] name = "zesdex-web" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "axum", @@ -5098,7 +5098,7 @@ dependencies = [ [[package]] name = "zesdex-ws" -version = "1.19.6" +version = "1.20.0" dependencies = [ "anyhow", "axum", diff --git a/apps/infrastructure/src/tools/context.rs b/apps/infrastructure/src/tools/context.rs index 19d5c90..c8d7aaf 100644 --- a/apps/infrastructure/src/tools/context.rs +++ b/apps/infrastructure/src/tools/context.rs @@ -66,6 +66,14 @@ impl ToolCtxBuilder { self.session_dir = v; self } + pub fn memory_dir(mut self, v: PathBuf) -> Self { + self.memory_dir = v; + self + } + pub fn worktrees_dir(mut self, v: PathBuf) -> Self { + self.worktrees_dir = v; + self + } pub fn workspaces(mut self, v: Vec) -> Self { self.workspaces = v; self diff --git a/apps/infrastructure/src/tools/memory/forget.rs b/apps/infrastructure/src/tools/memory/forget.rs index 380e6a7..4249fa1 100644 --- a/apps/infrastructure/src/tools/memory/forget.rs +++ b/apps/infrastructure/src/tools/memory/forget.rs @@ -43,7 +43,8 @@ impl Tool for Forget { let name = crate::tools::arg_str(args, "name")?; info!(name, "forget invoked"); let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new(); - repo.delete(&ctx.memory_dir, &name)?; + let memory_dir = crate::tools::memory::resolve_memory_dir(&ctx.memory_dir); + repo.delete(&memory_dir, &name)?; info!(name, "memory deleted"); Ok(format!("Memory '{}' deleted", name)) } diff --git a/apps/infrastructure/src/tools/memory/mod.rs b/apps/infrastructure/src/tools/memory/mod.rs index d52fe72..1473012 100644 --- a/apps/infrastructure/src/tools/memory/mod.rs +++ b/apps/infrastructure/src/tools/memory/mod.rs @@ -1,5 +1,21 @@ //! Memory management tools — remember, recall, forget. +use std::path::PathBuf; + pub mod forget; pub mod recall; pub mod remember; + +/// Resolve the directory the memory tools should read/write. +/// +/// Prefer an explicitly-configured `ToolCtx.memory_dir`. If that is empty +/// (a `ToolCtx` is often built without setting `memory_dir`), fall back to +/// the canonical persistent memory location from `Store` so memories are not +/// silently written into the current working directory. +pub fn resolve_memory_dir(ctx_memory_dir: &std::path::Path) -> PathBuf { + if ctx_memory_dir.as_os_str().is_empty() { + zesdex_domain::core::Store::new().memory_dir + } else { + ctx_memory_dir.to_path_buf() + } +} diff --git a/apps/infrastructure/src/tools/memory/recall.rs b/apps/infrastructure/src/tools/memory/recall.rs index 28f7db3..5709e87 100644 --- a/apps/infrastructure/src/tools/memory/recall.rs +++ b/apps/infrastructure/src/tools/memory/recall.rs @@ -45,21 +45,132 @@ impl Tool for Recall { #[instrument(skip(self, ctx, args))] fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new(); + let memory_dir = crate::tools::memory::resolve_memory_dir(&ctx.memory_dir); let specific_name = args.get("name").and_then(|v| v.as_str()); + let search = args.get("search").and_then(|v| v.as_str()); if let Some(name) = specific_name { info!(name, "recall loading specific memory"); - let memory = repo.load(&ctx.memory_dir, name)?; - Ok(serde_json::to_string_pretty(&memory)?) - } else { - info!("recall listing all memories"); - let names = repo.list(&ctx.memory_dir)?; - if names.is_empty() { - info!("no memories found"); - return Ok("No memories saved yet".to_string()); - } - Ok(format!("Available memories:\n{}", names.join("\n"))) + let memory = repo.load(&memory_dir, name)?; + return Ok(serde_json::to_string_pretty(&memory)?); } + + if let Some(query) = search { + let query = query.trim().to_lowercase(); + info!(search = %query, "recall searching memories"); + if query.is_empty() { + return Ok("Search query is empty".to_string()); + } + let names = repo.list(&memory_dir)?; + let mut matches: Vec = Vec::new(); + for name in &names { + // Load each memory and match against name/description/content. + if let Ok(m) = repo.load(&memory_dir, name) { + let haystack = + format!("{} {} {}", m.name, m.description, m.content).to_lowercase(); + if haystack.contains(&query) { + matches.push(m.name); + } + } + } + if matches.is_empty() { + return Ok(format!("No memories match '{query}'")); + } + return Ok(format!( + "Memories matching '{query}' ({}):\n{}", + matches.len(), + matches.join("\n") + )); + } + + info!("recall listing all memories"); + let names = repo.list(&memory_dir)?; + if names.is_empty() { + info!("no memories found"); + return Ok("No memories saved yet".to_string()); + } + Ok(format!("Available memories:\n{}", names.join("\n"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolCtxBuilder; + use zesdex_domain::cms::MemoryRepository; + + fn save_mem(name: &str, description: &str, content: &str, dir: &std::path::Path) { + let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new(); + let memory = zesdex_domain::cms::Memory { + name: name.to_string(), + description: description.to_string(), + content: content.to_string(), + kind: "reference".to_string(), + created_at: 0, + updated_at: 0, + outcome: None, + lifecycle: "active".to_string(), + scope: None, + before_snippet: None, + after_snippet: None, + provenances: Vec::new(), + }; + repo.save(dir, &memory).unwrap(); + } + + #[test] + fn search_filters_memories_by_keyword() { + let dir = std::env::temp_dir().join(format!("zdx-mem-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + save_mem( + "rust-concurrency", + "tokio spawn patterns", + "how to use async tasks", + &dir, + ); + save_mem( + "docker-deploy", + "deploy via compose", + "container orchestration", + &dir, + ); + + let tool_ctx = ToolCtxBuilder::default().memory_dir(dir.clone()).build(); + let args = serde_json::json!({ "search": "tokio" }); + + let out = Recall.run(&tool_ctx, &args).unwrap(); + assert!( + out.contains("rust-concurrency"), + "should match rust-concurrency, got: {out}" + ); + assert!( + !out.contains("docker-deploy"), + "docker-deploy should not match tokio" + ); + + // A query with no match reports so. + let no_match = Recall + .run(&tool_ctx, &serde_json::json!({ "search": "zzzznope" })) + .unwrap(); + assert!(no_match.contains("No memories match"), "{no_match}"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn resolve_memory_dir_falls_back_to_store_when_empty() { + // An empty ToolCtx.memory_dir is resolved to the canonical Store path. + let resolved = crate::tools::memory::resolve_memory_dir(std::path::Path::new("")); + assert!(!resolved.as_os_str().is_empty()); + assert!( + resolved.ends_with("memory"), + "expected memory dir, got {resolved:?}" + ); + + // An explicit memory_dir is preserved. + let explicit = + crate::tools::memory::resolve_memory_dir(std::path::Path::new("/tmp/custom-memory")); + assert_eq!(explicit, std::path::Path::new("/tmp/custom-memory")); } } diff --git a/apps/infrastructure/src/tools/memory/remember.rs b/apps/infrastructure/src/tools/memory/remember.rs index f041cf3..8dbc26e 100644 --- a/apps/infrastructure/src/tools/memory/remember.rs +++ b/apps/infrastructure/src/tools/memory/remember.rs @@ -81,7 +81,8 @@ impl Tool for Remember { }; let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new(); - repo.save(&ctx.memory_dir, &memory)?; + let memory_dir = crate::tools::memory::resolve_memory_dir(&ctx.memory_dir); + repo.save(&memory_dir, &memory)?; info!(name, "memory saved"); Ok(format!("Memory '{}' saved", name)) diff --git a/apps/infrastructure/src/tools/workflow.rs b/apps/infrastructure/src/tools/workflow.rs index 3aca073..d644577 100644 --- a/apps/infrastructure/src/tools/workflow.rs +++ b/apps/infrastructure/src/tools/workflow.rs @@ -9,7 +9,6 @@ use anyhow::Result; use serde_json::{json, Value}; use tracing::{debug, info, instrument, warn}; -use crate::llm::provider::LlmClient; use crate::tools::{arg_str, Tool, ToolCtx}; use crate::workflow::engine::execution::execute_workflow; use crate::workflow::hive_mind::cycle::execute_cycle; @@ -60,14 +59,8 @@ impl Tool for WorkflowRun { phase_names.join(", ") ); - let llm_client = LlmClient::new( - crate::llm::provider::DEFAULT_API_KEY.to_string(), - zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string(), - None, - ); let rt = crate::runtime::runtime(); - let result: Vec = - rt.block_on(async { execute_workflow(&script, ctx, &llm_client).await })?; + let result: Vec = rt.block_on(async { execute_workflow(&script, ctx).await })?; info!(phase_count = result.len(), "Workflow completed"); Ok(format!( diff --git a/apps/infrastructure/src/workflow/engine/execution.rs b/apps/infrastructure/src/workflow/engine/execution.rs index 1f14970..24ce44f 100644 --- a/apps/infrastructure/src/workflow/engine/execution.rs +++ b/apps/infrastructure/src/workflow/engine/execution.rs @@ -3,7 +3,6 @@ use anyhow::Result; use tracing::{info, instrument}; -use crate::llm::provider::LlmClient; use crate::tools::ToolCtx; use crate::workflow::engine::primitives::execute_primitive; use zesdex_domain::workflow::WorkflowScript; @@ -11,12 +10,8 @@ use zesdex_domain::workflow::WorkflowScript; /// Execute each phase of a workflow script sequentially. /// /// Flow: for each phase → execute_primitive → collect result. -#[instrument(skip(tool_ctx, _llm_client))] -pub async fn execute_workflow( - script: &WorkflowScript, - tool_ctx: &ToolCtx, - _llm_client: &LlmClient, -) -> Result> { +#[instrument(skip(tool_ctx))] +pub async fn execute_workflow(script: &WorkflowScript, tool_ctx: &ToolCtx) -> Result> { info!( "Executing workflow: {} ({} phases)", script.name,