Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e349a35716 | ||
|
|
f1f58b9996 | ||
|
|
f4c02fd64e | ||
|
|
e982cbeb04 |
@@ -1,3 +1,17 @@
|
||||
## [1.20.2](https://github.com/asepharyana/zesdex/compare/v1.20.1...v1.20.2) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** recall search + memory_dir fallback + bersihkan dead llm_client ([f1f58b9](https://github.com/asepharyana/zesdex/commit/f1f58b9996f8eb58871d44fdd41c2d863874f253))
|
||||
|
||||
## [1.20.1](https://github.com/asepharyana/zesdex/compare/v1.20.0...v1.20.1) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** subagent patuhi tool-calling contract + truncation char-safe ([e982cbe](https://github.com/asepharyana/zesdex/commit/e982cbeb041baea9cd500e2a29862a7eca9e6e17))
|
||||
|
||||
# [1.20.0](https://github.com/asepharyana/zesdex/compare/v1.19.6...v1.20.0) (2026-08-28)
|
||||
|
||||
|
||||
|
||||
Generated
+11
-11
@@ -4862,7 +4862,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-api"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -4885,7 +4885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-application"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -4903,7 +4903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-bootstrap"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -4920,7 +4920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-daemon"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -4944,7 +4944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-domain"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -4960,7 +4960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-gateway"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -4987,7 +4987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-grpc"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -5004,7 +5004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-infrastructure"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -5052,7 +5052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-tui"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -5078,7 +5078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-web"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -5098,7 +5098,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-ws"
|
||||
version = "1.19.5"
|
||||
version = "1.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.20.0"
|
||||
version = "1.20.2"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ pub fn spawn_background_review(
|
||||
);
|
||||
format!(
|
||||
"{}...\n[diff truncated at {} characters]",
|
||||
&diff[..MAX_DIFF_CHARS],
|
||||
crate::utils::truncate_chars(&diff, MAX_DIFF_CHARS),
|
||||
MAX_DIFF_CHARS
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -220,7 +220,7 @@ pub async fn run_agent(
|
||||
.await?;
|
||||
|
||||
let content = response_msg.content.clone().unwrap_or_default();
|
||||
let tool_calls = response_msg.tool_calls.unwrap_or_default();
|
||||
let tool_calls = response_msg.tool_calls.clone().unwrap_or_default();
|
||||
|
||||
// If no tool calls, we're done — return content
|
||||
if tool_calls.is_empty() {
|
||||
@@ -229,6 +229,12 @@ pub async fn run_agent(
|
||||
return Ok(content);
|
||||
}
|
||||
|
||||
// Push the assistant message (with its tool_calls) BEFORE executing
|
||||
// so the tool-calling contract is honoured: tool results reference
|
||||
// the calls declared in the preceding assistant message. Without
|
||||
// this, the history is malformed (`[...tool, tool, assistant]`).
|
||||
messages.push(response_msg);
|
||||
|
||||
// Execute tool calls — read-only batches run concurrently (bounded,
|
||||
// order preserved); any mutating tool forces the safe sequential path.
|
||||
let results = execute_tool_batch(&tools, &tool_ctx, &tool_calls);
|
||||
@@ -266,11 +272,6 @@ pub async fn run_agent(
|
||||
|
||||
messages.push(ChatMessage::tool(id, truncate_tool_output(result)));
|
||||
}
|
||||
|
||||
// Add assistant response if there was text content
|
||||
if !content.is_empty() {
|
||||
messages.push(ChatMessage::assistant(Some(content)));
|
||||
}
|
||||
}
|
||||
|
||||
info!("Subagent reached iteration limit ({MAX_ITERATIONS})");
|
||||
|
||||
@@ -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<PathBuf>) -> Self {
|
||||
self.workspaces = v;
|
||||
self
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,21 +45,132 @@ impl Tool for Recall {
|
||||
#[instrument(skip(self, ctx, args))]
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
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 {
|
||||
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<String> = 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(&ctx.memory_dir)?;
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<String> =
|
||||
rt.block_on(async { execute_workflow(&script, ctx, &llm_client).await })?;
|
||||
let result: Vec<String> = rt.block_on(async { execute_workflow(&script, ctx).await })?;
|
||||
|
||||
info!(phase_count = result.len(), "Workflow completed");
|
||||
Ok(format!(
|
||||
|
||||
@@ -275,7 +275,7 @@ pub fn build_rich_context(root: &Path) -> String {
|
||||
let p = root.join(file);
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
let snippet = if content.len() > 1500 {
|
||||
format!("{}\n... (truncated)", &content[..1500])
|
||||
format!("{}\n... (truncated)", truncate_chars(&content, 1500))
|
||||
} else {
|
||||
content
|
||||
};
|
||||
@@ -301,7 +301,7 @@ pub fn build_rich_context(root: &Path) -> String {
|
||||
let readme_path = root.join("README.md");
|
||||
if let Ok(content) = std::fs::read_to_string(&readme_path) {
|
||||
let snippet = if content.len() > 1000 {
|
||||
format!("{}\n... (truncated)", &content[..1000])
|
||||
format!("{}\n... (truncated)", truncate_chars(&content, 1000))
|
||||
} else {
|
||||
content
|
||||
};
|
||||
@@ -348,3 +348,53 @@ pub fn build_rich_context(root: &Path) -> String {
|
||||
|
||||
ctx.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `max_chars` **characters**, never cutting a
|
||||
/// multi-byte UTF-8 code point in half.
|
||||
///
|
||||
/// `&s[..n]` with `n` a raw byte index panics when `n` lands inside a
|
||||
/// multi-byte character (e.g. an emoji, `→`, or CJK in a README/diff). This
|
||||
/// helper slices on character boundaries so content is safely capped at a
|
||||
/// byte budget while remaining valid UTF-8.
|
||||
pub fn truncate_chars(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
s.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_chars_leaves_short_strings_unchanged() {
|
||||
assert_eq!(truncate_chars("short", 100), "short");
|
||||
assert_eq!(truncate_chars("", 5), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chars_cuts_to_max_chars() {
|
||||
assert_eq!(truncate_chars("hello world", 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chars_never_splits_multibyte_utf8() {
|
||||
// 4 chars each: '→' is 3 bytes. Byte-slicing at 5 would panic; char
|
||||
// slicing must not.
|
||||
let s = "a→b→c→d";
|
||||
let t = truncate_chars(s, 5);
|
||||
assert_eq!(t, "a→b→c");
|
||||
assert!(t.chars().count() <= 5);
|
||||
// No replacement char must appear (valid UTF-8 preserved).
|
||||
assert!(!t.contains('\u{FFFD}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chars_handles_emoji() {
|
||||
let s = "🚀🚀🚀🚀";
|
||||
let t = truncate_chars(s, 2);
|
||||
assert_eq!(t, "🚀🚀");
|
||||
assert!(t.chars().count() == 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
#[instrument(skip(tool_ctx))]
|
||||
pub async fn execute_workflow(script: &WorkflowScript, tool_ctx: &ToolCtx) -> Result<Vec<String>> {
|
||||
info!(
|
||||
"Executing workflow: {} ({} phases)",
|
||||
script.name,
|
||||
|
||||
Reference in New Issue
Block a user