Compare commits

..
11 Commits
Author SHA1 Message Date
semantic-release-bot f4c02fd64e chore(release): 1.20.1 [skip ci]
## [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))
2026-08-28 04:47:39 +00:00
asepharyana e982cbeb04 fix(agent): subagent patuhi tool-calling contract + truncation char-safe
Hasil audit alur AI agent round 3 (fokus correctness & latent crash).

- fix(subagent): engine.rs sebelumnya mengeksekusi tool lalu push
  ChatMessage::tool hasil TANPA mendahuluinya dengan pesan assistant yang
  mendeklarasikan tool_calls → history malformed ([..., tool, tool,
  assistant(text)]). Kontrak OpenAI/Anthropic mensyaratkan pesan assistant
  (berisi tool_calls) sebelum hasil tool. Kini push response_msg
  (assistant + tool_calls + content) sebelum eksekusi, dan hapus push
  assistant content-only di akhir (agar tidak duplikat). Loop utama sudah
  benar; subagent kini selaras.
- fix(utils): &content[..1500] / &content[..1000] di build_rich_context
  dan &diff[..5000] di auto/engine.rs bisa panic saat indeks byte jatuh di
  tengah karakter multi-byte UTF-8 (emoji/CJK/panah). Tambah helper
  truncate_chars() yang memotong per karakter (char-safe) dan pakai di
  3 titik tersebut.
- test: +4 unit test truncate_chars (ASCII, potong, multibyte no-panic,
  emoji).

Catatan audit: subagent/auto (auto-review) & build_rich_context adalah dead
code (spawn_background_review & build_rich_context tidak pernah dipanggil).
Auto-review jangan diaktifkan asal (parser format teks rapuh + tanpa
verifikasi pasca-fix) — dilaporkan, bukan dicolokkan.
2026-08-28 11:43:42 +07:00
semantic-release-bot 20ce81a6be chore(release): 1.20.0 [skip ci]
# [1.20.0](https://github.com/asepharyana/zesdex/compare/v1.19.6...v1.20.0) (2026-08-28)

### Features

* **agent:** subagent tool paralel + auto-load AGENTS.md + verify cek setelah edit ([21e3ccc](https://github.com/asepharyana/zesdex/commit/21e3ccc891ab886044a5f0a770db710769d1287b))
2026-08-28 02:54:09 +00:00
asepharyana 21e3ccc891 feat(agent): subagent tool paralel + auto-load AGENTS.md + verify cek setelah edit
Lanjutan audit alur AI agent (round 2), mengisi celah yang tersisa dari
perpbaikan paralel tool di loop utama (74b1ad4) agar lebih mirip Claude Code.

- feat(subagent): eksekusi batch tool read-only paralel di subagent engine
  (engine.rs). Tool::run sinkron, jadi pakai scoped OS thread (bounded
  window 8); hasil dipertahankan dalam urutan panggilan asli. Batch dengan
  tool mutating jatuh balik ke jalur sequential aman.
- feat(agent): auto-load AGENTS.md/CLAUDE.md/.cursorrules ke system prompt
  tiap turn (seperti Claude Code load AGENTS.md saat startup). Fungsi
  main_agent_prompt_with_project_context menempel blok PROJECT CONTEXT;
  dibaca dari workspace root pertama & dibatasi 12k char.
- feat(prompt): arahan VERIFY AFTER EDIT — setelah edit/write, agent wajib
  jalankan cargo check/clippy/test (atau lint/test sesuai stack) via bash
  sebelum mengakhiri turn; perbaiki error yang terlihat, jangan klaim
  'compiles/works' tanpa hasil nyata.
- feat(infra): build_rich_context kini membaca AGENTS.md & CLAUDE.md juga
  (untuk explore_codebase/scout).
- test: +3 subagent engine (order paralel, kecepatan konkuren, fallback
  mutating), +2 domain prompt (konteks proyek & fallback kosong).
2026-08-28 09:50:21 +07:00
semantic-release-bot 992e60980c chore(release): 1.19.6 [skip ci]
## [1.19.6](https://github.com/asepharyana/zesdex/compare/v1.19.5...v1.19.6) (2026-08-27)

### Performance Improvements

* **agent:** eksekusi tool read-only paralel seperti Claude Code ([74b1ad4](https://github.com/asepharyana/zesdex/commit/74b1ad43020a11ca27e60e3a24de0db5d1ab37b4))
2026-08-27 18:22:01 +00:00
asepharyana 74b1ad4302 perf(agent): eksekusi tool read-only paralel seperti Claude Code
Sebelumnya loop utama mengeksekusi semua tool call satu-per-satu
(sequential for loop). Seperti Claude Code, tool read-only yang
independen dalam satu pesan assistant (read/grep/glob/semantic_search
dsb.) kini dijalankan konkuren dengan bounded parallelism (max 8),
mengurangi latensi per turn secara signifikan untuk beban coding.

- feat(registry): tool_is_parallel_safe() — whitelist tool read-only
  yang aman dijalankan paralel; tool mutating/shell tetap sequential
- feat(executor): is_parallel_safe() delegasi ke registry; ToolExecutor
  trait Default=false (konservatif)
- fix(application): execute_tool_calls_in_parallel() — join_all +
  semaphore bounded 8, hasil dikumpulkan dalam URUTAN panggilan asli
  (kontrak OpenAI/Anthropic tool-result ordering)
- loop utama: batch paralel hanya jika SEMUA tool parallel-safe; jika
  ada satu tool mutating, jatuh balik ke jalur sequential aman
- test: +2 registry test, +2 application test (konkurensi & urutan,
  fallback batch mutating)
2026-08-28 01:17:26 +07:00
semantic-release-bot 9ad04cf819 chore(release): 1.19.5 [skip ci]
## [1.19.5](https://github.com/asepharyana/zesdex/compare/v1.19.4...v1.19.5) (2026-08-27)

### Bug Fixes

* **api:** model Opus default pakai claude-opus-5 (bukan -4-8) ([b28a5fe](https://github.com/asepharyana/zesdex/commit/b28a5fe384fd45255a6b249c7febd3b4ffc0fd2f))
* **api:** update zesdex packages to version 1.19.4 ([b46935c](https://github.com/asepharyana/zesdex/commit/b46935c606d4f68ea227c7db27e4c2aa9b4e373c))
2026-08-27 17:36:59 +00:00
asepharyana b46935c606 fix(api): update zesdex packages to version 1.19.4 2026-08-28 00:32:27 +07:00
asepharyana b28a5fe384 fix(api): model Opus default pakai claude-opus-5 (bukan -4-8)
Model terbaru di 9router adalah claude-opus-5. Update semua jalur
model default Opus:

- fix(app_config_repo): fallback default_model custom_model.unwrap_or
  -> claude-opus-5; model_roles list claude-opus-5
- fix(app_config): router provider default_model -> claude-opus-5
- fix(settings test): assertion claude-opus-5
- fix(data): ~/.local/share/zesdex/settings.json model -> claude-opus-5
2026-08-28 00:32:06 +07:00
semantic-release-bot b66898ea28 chore(release): 1.19.4 [skip ci]
## [1.19.4](https://github.com/asepharyana/zesdex/compare/v1.19.3...v1.19.4) (2026-08-27)

### Bug Fixes

* **api:** model claude selalu pakai Opus dari settings.json, bukan deepseek ([9aca45c](https://github.com/asepharyana/zesdex/commit/9aca45cb65d6913d14fecc10d70c180934f69d74))
2026-08-27 17:05:10 +00:00
asepharyana 9aca45cb65 fix(api): model claude selalu pakai Opus dari settings.json, bukan deepseek
TUI turn.rs & daemon handler.rs ambil model langsung dari
settings.model (tersimpan 'deepseek-v4-flash-free' di
~/.local/share/zesdex/settings.json) padahal provider sudah 'claude'.

- feat(domain): resolve_effective_model() — saat provider claude, model
  diambil dari app_config provider claude (default_model=claude-opus-4-8
  hasil deteksi ~/.claude/settings.json), menang atas settings.model basi.
  Provider non-claude tetap hormati settings.model user.
- fix(tui): turn.rs pakai resolve_effective_model (bukan settings.model)
- fix(daemon): handler.rs run_turn + compaction pakai resolve_effective_model
- fix(data): ~/.local/share/zesdex/settings.json model deepseek -> claude-opus-4-8
- test: 3 unit test resolve_effective_model
2026-08-28 00:00:32 +07:00
20 changed files with 801 additions and 67 deletions
+36
View File
@@ -1,3 +1,39 @@
## [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)
### Features
* **agent:** subagent tool paralel + auto-load AGENTS.md + verify cek setelah edit ([21e3ccc](https://github.com/asepharyana/zesdex/commit/21e3ccc891ab886044a5f0a770db710769d1287b))
## [1.19.6](https://github.com/asepharyana/zesdex/compare/v1.19.5...v1.19.6) (2026-08-27)
### Performance Improvements
* **agent:** eksekusi tool read-only paralel seperti Claude Code ([74b1ad4](https://github.com/asepharyana/zesdex/commit/74b1ad43020a11ca27e60e3a24de0db5d1ab37b4))
## [1.19.5](https://github.com/asepharyana/zesdex/compare/v1.19.4...v1.19.5) (2026-08-27)
### Bug Fixes
* **api:** model Opus default pakai claude-opus-5 (bukan -4-8) ([b28a5fe](https://github.com/asepharyana/zesdex/commit/b28a5fe384fd45255a6b249c7febd3b4ffc0fd2f))
* **api:** update zesdex packages to version 1.19.4 ([b46935c](https://github.com/asepharyana/zesdex/commit/b46935c606d4f68ea227c7db27e4c2aa9b4e373c))
## [1.19.4](https://github.com/asepharyana/zesdex/compare/v1.19.3...v1.19.4) (2026-08-27)
### Bug Fixes
* **api:** model claude selalu pakai Opus dari settings.json, bukan deepseek ([9aca45c](https://github.com/asepharyana/zesdex/commit/9aca45cb65d6913d14fecc10d70c180934f69d74))
## [1.19.3](https://github.com/asepharyana/zesdex/compare/v1.19.2...v1.19.3) (2026-08-27) ## [1.19.3](https://github.com/asepharyana/zesdex/compare/v1.19.2...v1.19.3) (2026-08-27)
Generated
+12 -11
View File
@@ -4862,7 +4862,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-api" name = "zesdex-api"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -4885,11 +4885,12 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-application" name = "zesdex-application"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"chrono", "chrono",
"futures-util",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
@@ -4902,7 +4903,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-bootstrap" name = "zesdex-bootstrap"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -4919,7 +4920,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-daemon" name = "zesdex-daemon"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4943,7 +4944,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-domain" name = "zesdex-domain"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4959,7 +4960,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-gateway" name = "zesdex-gateway"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -4986,7 +4987,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-grpc" name = "zesdex-grpc"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5003,7 +5004,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-infrastructure" name = "zesdex-infrastructure"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -5051,7 +5052,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-tui" name = "zesdex-tui"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -5077,7 +5078,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-web" name = "zesdex-web"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5097,7 +5098,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-ws" name = "zesdex-ws"
version = "1.19.1" version = "1.19.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+1 -1
View File
@@ -15,7 +15,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.19.3" version = "1.20.1"
edition = "2021" edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"] authors = ["asepharyana <superaseph@gmail.com>"]
+1
View File
@@ -16,6 +16,7 @@ uuid.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tokio.workspace = true tokio.workspace = true
futures-util.workspace = true
base64.workspace = true base64.workspace = true
sha2.workspace = true sha2.workspace = true
url.workspace = true url.workspace = true
+11
View File
@@ -11,6 +11,17 @@ pub trait ToolExecutor: Send + Sync {
tool_name: &str, tool_name: &str,
args: &serde_json::Value, args: &serde_json::Value,
) -> impl Future<Output = Result<String>> + Send; ) -> impl Future<Output = Result<String>> + Send;
/// Whether a tool is *read-only* and therefore safe to run concurrently
/// with other read-only tools in the same assistant message.
///
/// Defaults to `false` (sequential) so a caller that does not know the
/// tool surface stays conservative. Concrete executors that know their
/// tools override this — e.g. return `true` for `read`/`grep`/`glob`.
fn is_parallel_safe(&self, tool_name: &str) -> bool {
let _ = tool_name;
false
}
} }
/// Service for running agent turns asynchronously. /// Service for running agent turns asynchronously.
+210 -5
View File
@@ -5,7 +5,7 @@ use tracing::{debug, info, warn};
use zesdex_domain::agent::{AgentTurnParams, TurnEvent}; use zesdex_domain::agent::{AgentTurnParams, TurnEvent};
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef}; use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
use zesdex_domain::main_agent_prompt; use zesdex_domain::main_agent_prompt_with_project_context;
use super::ToolExecutor; use super::ToolExecutor;
use crate::ports::ProviderService; use crate::ports::ProviderService;
@@ -111,6 +111,46 @@ fn conversation_chars(messages: &[ChatMessage]) -> usize {
.sum() .sum()
} }
/// The maximum combined size (characters) of project-rule files injected into
/// the system prompt, so a huge AGENTS.md cannot blow the context window.
const PROJECT_CONTEXT_MAX_CHARS: usize = 12_000;
/// Case-insensitive rule filenames auto-loaded from the workspace root into
/// the system prompt, matching the Claude-Code/AGENTS.md convention.
const RULE_FILENAMES: [&str; 6] = [
"AGENTS.md",
"agent.md",
"CLAUDE.md",
"claude.md",
".cursorrules",
".zesdexrules",
];
/// Build a compact "project context" block from the repo's convention files
/// (AGENTS.md, CLAUDE.md, .cursorrules, …) found at the workspace root.
///
/// Follows the Claude-Code convention of loading AGENTS.md at startup so the
/// model starts each turn with the repo's rules. Reads are best-effort and
/// capped at [`PROJECT_CONTEXT_MAX_CHARS`] total; missing files are skipped.
fn build_project_context(root: &std::path::Path) -> String {
let mut ctx = String::new();
for file in RULE_FILENAMES {
let full = root.join(file);
if let Ok(content) = std::fs::read_to_string(&full) {
ctx.push_str(&format!("\n### {file}\n```\n{}\n```", content.trim()));
}
}
let context = ctx.trim().to_string();
if context.len() <= PROJECT_CONTEXT_MAX_CHARS {
return context;
}
context
.chars()
.take(PROJECT_CONTEXT_MAX_CHARS)
.collect::<String>()
+ "\n...[project context truncated]"
}
/// Track repeated tool-call errors so the loop can recover instead of /// Track repeated tool-call errors so the loop can recover instead of
/// burning iterations retrying the same failing tool. /// burning iterations retrying the same failing tool.
#[derive(Default)] #[derive(Default)]
@@ -187,6 +227,53 @@ async fn execute_tool_call<T: ToolExecutor>(
output output
} }
// ---------------------------------------------------------------------------
// Helper: bounded-parallel execution of read-only tool calls.
// ---------------------------------------------------------------------------
/// Maximum number of read-only tool calls executed concurrently in a single
/// assistant batch. Models rarely emit more than a handful of reads per
/// message; this cap keeps resource usage bounded while still removing the
/// serial round-trip latency of many independent lookups.
const MAX_PARALLEL_TOOLS: usize = 8;
fn tool_executor_ref<T: ToolExecutor>(tool_executor: &T) -> &T {
tool_executor
}
/// Execute a batch of *read-only* tool calls concurrently (bounded by
/// [`MAX_PARALLEL_TOOLS`]) and return their outputs **in the original call
/// order**.
///
/// Order preservation matters: OpenAI/Anthropic tool-calling contracts expect
/// tool-result messages to appear in the same order as the `tool_calls`
/// emitted in the assistant message. Without it, the model sees shuffled
/// results and loses track of which result belongs to which call.
///
/// Each call still pushes its `TurnEvent::ToolResult` (so the TUI shows each
/// tool as it completes) but the returned `Vec` is ordered by the input index.
async fn execute_tool_calls_in_parallel<T: ToolExecutor>(
tool_executor: &T,
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
tool_calls: &[zesdex_domain::core::ToolCall],
) -> Vec<String> {
let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_PARALLEL_TOOLS));
let executor_ref = tool_executor_ref(tool_executor);
let futures = tool_calls.iter().map(|tc| {
let tc = tc.clone();
let events = turn_events.clone();
let sem = semaphore.clone();
async move {
// Acquire a permit to bound concurrency across the batch.
let _permit = sem.acquire_owned().await;
execute_tool_call(executor_ref, &events, &tc).await
}
});
futures_util::future::join_all(futures).await
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helper: emit usage event from optional LLM response metadata. // Helper: emit usage event from optional LLM response metadata.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -294,9 +381,17 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
// Insert system prompt at position 0 once and keep it there for the // Insert system prompt at position 0 once and keep it there for the
// entire turn, avoiding per-iteration clones of the full message list. // entire turn, avoiding per-iteration clones of the full message list.
// Auto-load repo conventions (AGENTS.md / CLAUDE.md / .cursorrules)
// from the first workspace root, like Claude Code does at startup.
let project_context = params
.workspace_roots
.first()
.map(|root| build_project_context(root))
.unwrap_or_default();
let system_prompt = main_agent_prompt_with_project_context(&project_context);
params params
.messages .messages
.insert(0, ChatMessage::system(main_agent_prompt())); .insert(0, ChatMessage::system(system_prompt));
let original_count = params.messages.len(); let original_count = params.messages.len();
// Estimate request complexity from the last user message. // Estimate request complexity from the last user message.
@@ -384,10 +479,42 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
params.messages.push(assistant_msg); params.messages.push(assistant_msg);
// ── Execute each tool call ────────────────────────── // ── Execute each tool call ──────────────────────────
//
// If the whole batch is made of *read-only* tools
// (read/grep/glob/…), run it concurrently with bounded
// parallelism — a big latency win for coding turns that
// emit several independent lookups in one message. Any
// single mutating tool forces the whole batch back to the
// safe sequential path so writes never race.
//
// Results are always collected in the original call order
// to honour the tool-calling contract.
let parallel = tool_calls.len() > 1
&& tool_calls
.iter()
.all(|tc| self.tool_executor.is_parallel_safe(&tc.function.name));
let outputs: Vec<String> = if parallel {
execute_tool_calls_in_parallel(
self.tool_executor.as_ref(),
&params.turn_events,
&tool_calls,
)
.await
} else {
let mut sequential = Vec::with_capacity(tool_calls.len());
for tc in &tool_calls { for tc in &tool_calls {
let output = let out = execute_tool_call(
execute_tool_call(self.tool_executor.as_ref(), &params.turn_events, tc) self.tool_executor.as_ref(),
&params.turn_events,
tc,
)
.await; .await;
sequential.push(out);
}
sequential
};
for (tc, output) in tool_calls.iter().zip(outputs) {
if output.starts_with("Error:") { if output.starts_with("Error:") {
errors.record(&tc.function.name, &output, &mut params.messages); errors.record(&tc.function.name, &output, &mut params.messages);
} }
@@ -479,7 +606,6 @@ pub async fn compact_messages_with_ai<P: ProviderService>(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn truncate_short_output_is_unchanged() { fn truncate_short_output_is_unchanged() {
let out = "short".to_string(); let out = "short".to_string();
@@ -536,4 +662,83 @@ mod tests {
]; ];
assert_eq!(conversation_chars(&messages), 3 + 11 + 6); assert_eq!(conversation_chars(&messages), 3 + 11 + 6);
} }
/// A fake executor that reports parallel-safety for read-only tools and
/// whose `execute` sleeps on the first call to prove the batch runs
/// concurrently (a sequential loop would pay the sleep per call).
struct FakeExecutor;
impl ToolExecutor for FakeExecutor {
async fn execute(&self, name: &str, _args: &serde_json::Value) -> anyhow::Result<String> {
if name == "read" {
// 30ms sleep on every read; a parallel batch of 3 would
// finish in ~30ms instead of ~90ms sequentially.
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
Ok(format!("out:{name}"))
}
fn is_parallel_safe(&self, name: &str) -> bool {
matches!(name, "read" | "grep")
}
}
fn tc(name: &str, id: usize) -> zesdex_domain::core::ToolCall {
zesdex_domain::core::ToolCall {
id: format!("call_{id}"),
type_: "function".to_string(),
function: zesdex_domain::core::ToolFunction {
name: name.to_string(),
arguments: serde_json::Value::String(String::new()),
},
}
}
#[test]
fn parallel_batch_runs_concurrently_and_preserves_order() {
let executor = FakeExecutor;
let events = Arc::new(Mutex::new(VecDeque::new()));
let calls = vec![tc("read", 1), tc("grep", 2), tc("read", 3)];
// All three are parallel-safe.
assert!(calls
.iter()
.all(|c| executor.is_parallel_safe(&c.function.name)));
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_time()
.build()
.unwrap();
let started = std::time::Instant::now();
let outputs = rt.block_on(execute_tool_calls_in_parallel(&executor, &events, &calls));
let elapsed = started.elapsed();
// Results are in *original* call order (read, grep, read).
assert_eq!(
outputs,
vec![
"out:read".to_string(),
"out:grep".to_string(),
"out:read".to_string()
]
);
// Two reads sleep 30ms each; sequential would take ~60ms+ for the
// two reads, parallel keeps the whole batch under 60ms.
assert!(
elapsed < std::time::Duration::from_millis(60),
"batch took {elapsed:?}, expected parallel execution"
);
assert!(elapsed >= std::time::Duration::from_millis(25));
}
#[test]
fn mutating_batch_falls_back_to_sequential_path() {
// A batch containing a mutating tool is not eligible for the parallel
// path, so the main loop keeps results ordered and side-effects safe.
let executor = FakeExecutor;
let calls = [tc("read", 1), tc("edit", 2)];
assert!(!calls
.iter()
.all(|c| executor.is_parallel_safe(&c.function.name)));
}
} }
+52
View File
@@ -41,10 +41,43 @@ architectural plans and `todowrite` to maintain granular task checklists.
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \ 4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \
within or guided by your workflows. If an error occurs, analyse and fix it. within or guided by your workflows. If an error occurs, analyse and fix it.
VERIFY AFTER EDIT (CLAUDE-CODE STYLE):
- After modifying code (edit/write), run the repo's check command via `bash` \
before ending the turn: `cargo check` / `cargo clippy` / `cargo test` for Rust, \
or the equivalent lint/test (`bun run lint && bun run test`, `npm test`, etc.) \
for other stacks. Pick the project's actual verify command (see PROJECT \
CONTEXT / AGENTS.md when present).
- If the check fails, fix the errors you can see and re-run; only end the turn \
after the check passes or you cannot resolve a failure yourself (then report it \
explicitly).
- Do NOT claim code compiles or works without running a real check.
Respond conversationally, concisely, and helpfully." Respond conversationally, concisely, and helpfully."
.to_string() .to_string()
} }
/// Build the main-agent system prompt including an injected block of project
/// context (AGENTS.md / CLAUDE.md / project rules).
///
/// Like Claude Code, which loads AGENTS.md at startup so the model starts with
/// the repo's conventions, this wraps [`main_agent_prompt`] and appends a
/// clearly-delimited `## PROJECT CONTEXT` section carrying the rules the user
/// keeps next to their code. When `project_context` is empty the returned
/// prompt is identical to [`main_agent_prompt`], so callers can fall back
/// safely.
pub fn main_agent_prompt_with_project_context(project_context: &str) -> String {
let base = main_agent_prompt();
let context = project_context.trim();
if context.is_empty() {
return base;
}
format!(
"{base}\n\n\
## PROJECT CONTEXT (repo rules — follow these conventions)\n\
{context}"
)
}
/// Build a subagent directive prompt. /// Build a subagent directive prompt.
/// ///
/// The directive is embedded in a system message that also communicates the /// The directive is embedded in a system message that also communicates the
@@ -119,6 +152,25 @@ mod tests {
assert!(prompt.contains("WORKFLOW FIRST")); assert!(prompt.contains("WORKFLOW FIRST"));
} }
#[test]
fn project_context_prompt_appends_context_and_keeps_base() {
let base = main_agent_prompt();
let with_ctx = main_agent_prompt_with_project_context("## AGENTS.md\nUse cargo clippy.");
assert!(with_ctx.contains("Zesdex"), "base prompt must be preserved");
assert!(with_ctx.contains("PROJECT CONTEXT"));
assert!(with_ctx.contains("Use cargo clippy."));
assert!(with_ctx.contains(&base));
// The base section should appear before the context section.
assert!(with_ctx.find("PROJECT CONTEXT").unwrap() > with_ctx.find("Zesdex").unwrap());
}
#[test]
fn empty_project_context_returns_base_prompt() {
let base = main_agent_prompt();
assert_eq!(main_agent_prompt_with_project_context(""), base);
assert_eq!(main_agent_prompt_with_project_context(" "), base);
}
#[test] #[test]
fn subagent_directive_includes_directive_text() { fn subagent_directive_includes_directive_text() {
let prompt = subagent_directive("test directive", "/home", "/home/project"); let prompt = subagent_directive("test directive", "/home", "/home/project");
+2 -2
View File
@@ -80,7 +80,7 @@ impl Default for AppConfig {
/// ///
/// ## Defaults /// ## Defaults
/// - Zen provider: `deepseek-v4-flash-free` model /// - Zen provider: `deepseek-v4-flash-free` model
/// - Router provider: `claude-opus-4-8` model /// - Router provider: `claude-opus-5` model
/// - Default role: "default" → zen / deepseek-v4-flash-free, temp 0.7 /// - Default role: "default" → zen / deepseek-v4-flash-free, temp 0.7
/// - `default_context_window`: 256,000 tokens /// - `default_context_window`: 256,000 tokens
fn default() -> Self { fn default() -> Self {
@@ -99,7 +99,7 @@ impl Default for AppConfig {
ProviderConfig { ProviderConfig {
api_base: "https://9router.asepharyana.my.id/v1".to_string(), api_base: "https://9router.asepharyana.my.id/v1".to_string(),
api_key_env: Some("ROUTER_API_KEY".to_string()), api_key_env: Some("ROUTER_API_KEY".to_string()),
default_model: Some("claude-opus-4-8".to_string()), default_model: Some("claude-opus-5".to_string()),
default_api_key: None, default_api_key: None,
}, },
); );
+1
View File
@@ -47,6 +47,7 @@ pub use repository::SettingsRepository;
pub use service::ConversationService; pub use service::ConversationService;
pub use service::MemoryService; pub use service::MemoryService;
pub use service::SettingsService; pub use service::SettingsService;
pub use settings::resolve_effective_model;
pub use settings::InternetMode; pub use settings::InternetMode;
pub use settings::Settings; pub use settings::Settings;
pub use settings::SettingsFlags; pub use settings::SettingsFlags;
+85
View File
@@ -23,6 +23,8 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::app_config::AppConfig;
/// Controls how much network access the agent is permitted during a session. /// Controls how much network access the agent is permitted during a session.
/// ///
/// ## Variants /// ## Variants
@@ -112,3 +114,86 @@ impl Default for Settings {
} }
} }
} }
/// Pick the effective model name for the main agent.
///
/// When `settings.provider` is `"claude"` (auto-detected from
/// `~/.claude/settings.json`), the provider's `default_model` (or the
/// app-level `default_model`) wins over a possibly-stale persisted
/// `settings.model`. Otherwise the user's explicit `settings.model` is used.
///
/// Why: the user's custom Claude endpoint (URL + API key from
/// `~/.claude/settings.json`) implies Opus as the model; a stale
/// `settings.json` (e.g. "deepseek-v4-flash-free") must not override it.
pub fn resolve_effective_model(settings: &Settings, app_config: &AppConfig) -> String {
if settings.provider == "claude" {
if let Some(m) = app_config
.providers
.get("claude")
.and_then(|p| p.default_model.clone())
{
return m;
}
return app_config.default_model.clone();
}
settings.model.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cms::app_config::AppConfig;
fn claude_app_config() -> AppConfig {
let mut cfg = AppConfig::default();
cfg.providers.insert(
"claude".to_string(),
crate::cms::ProviderConfig {
api_base: "https://9router.example/v1".to_string(),
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: Some("claude-opus-5".to_string()),
default_api_key: Some("sk-test".to_string()),
},
);
cfg.default_provider = "claude".to_string();
cfg.default_model = "claude-opus-5".to_string();
cfg
}
#[test]
fn claude_provider_uses_opus_model_over_stale_settings_model() {
let settings = Settings {
provider: "claude".to_string(),
model: "deepseek-v4-flash-free".to_string(), // stale persisted
..Settings::default()
};
let model = resolve_effective_model(&settings, &claude_app_config());
assert_eq!(model, "claude-opus-5");
}
#[test]
fn non_claude_provider_uses_settings_model() {
let settings = Settings {
provider: "zen".to_string(),
model: "my-model".to_string(),
..Settings::default()
};
let model = resolve_effective_model(&settings, &AppConfig::default());
assert_eq!(model, "my-model");
}
#[test]
fn claude_falls_back_to_app_default() {
let settings = Settings {
provider: "claude".to_string(),
model: String::new(),
..Settings::default()
};
let cfg = AppConfig::default();
let model = resolve_effective_model(&settings, &cfg);
assert_eq!(model, cfg.default_model);
}
}
+4 -1
View File
@@ -58,6 +58,9 @@ pub use agent::*;
// Sub-module items need explicit re-exports // Sub-module items need explicit re-exports
pub use agent::defaults::*; pub use agent::defaults::*;
pub use agent::progress::AgentProgress; pub use agent::progress::AgentProgress;
pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive}; pub use agent::prompt::{
compaction_prompt, main_agent_prompt, main_agent_prompt_with_project_context,
subagent_directive,
};
pub use subagent::*; pub use subagent::*;
pub use workflow::*; pub use workflow::*;
@@ -79,7 +79,7 @@ fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option<String>)>
/// persisted entry with the current base URL + key from settings.json). /// persisted entry with the current base URL + key from settings.json).
/// 2. Registers known Claude model roles if missing. /// 2. Registers known Claude model roles if missing.
/// 3. Always sets `default_provider = "claude"` and /// 3. Always sets `default_provider = "claude"` and
/// `default_model = custom_model.unwrap_or("claude-opus-4-8")` so Opus /// `default_model = custom_model.unwrap_or("claude-opus-5")` so Opus
/// is the default whenever `~/.claude/settings.json` is present. /// is the default whenever `~/.claude/settings.json` is present.
fn apply_claude_provider( fn apply_claude_provider(
cfg: &mut AppConfig, cfg: &mut AppConfig,
@@ -89,7 +89,7 @@ fn apply_claude_provider(
cfg.providers.insert("claude".to_string(), claude_provider); cfg.providers.insert("claude".to_string(), claude_provider);
let claude_models: [(&str, &str); 3] = [ let claude_models: [(&str, &str); 3] = [
("claude-opus-4-8", "claude-opus-4-8"), ("claude-opus-5", "claude-opus-5"),
("claude-sonnet-5", "claude-sonnet-5"), ("claude-sonnet-5", "claude-sonnet-5"),
("claude-haiku-4-5", "claude-haiku-4-5-20251001"), ("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
]; ];
@@ -118,7 +118,7 @@ fn apply_claude_provider(
// Always prefer the Claude provider + Opus model when settings.json // Always prefer the Claude provider + Opus model when settings.json
// is present — this is the user's explicit custom endpoint choice. // is present — this is the user's explicit custom endpoint choice.
cfg.default_provider = "claude".to_string(); cfg.default_provider = "claude".to_string();
cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-4-8".to_string()); cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-5".to_string());
} }
impl AppConfigRepository for JsonAppConfigRepository { impl AppConfigRepository for JsonAppConfigRepository {
@@ -159,7 +159,7 @@ mod tests {
ProviderConfig { ProviderConfig {
api_base: base.to_string(), api_base: base.to_string(),
api_key_env: Some("ANTHROPIC_API_KEY".to_string()), api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: Some("claude-opus-4-8".to_string()), default_model: Some("claude-opus-5".to_string()),
default_api_key: key.map(|s| s.to_string()), default_api_key: key.map(|s| s.to_string()),
} }
} }
@@ -209,10 +209,10 @@ mod tests {
assert_eq!(claude.default_api_key.as_deref(), Some("sk-new")); assert_eq!(claude.default_api_key.as_deref(), Some("sk-new"));
// Insert (not or_insert) → stale entry refreshed. // Insert (not or_insert) → stale entry refreshed.
assert_eq!(cfg.default_provider, "claude"); assert_eq!(cfg.default_provider, "claude");
assert_eq!(cfg.default_model, "claude-opus-4-8"); assert_eq!(cfg.default_model, "claude-opus-5");
// Claude model roles registered. // Claude model roles registered.
assert!(cfg.model_roles.contains_key("claude-opus-4-8")); assert!(cfg.model_roles.contains_key("claude-opus-5"));
assert!(cfg.model_roles.contains_key("claude-sonnet-5")); assert!(cfg.model_roles.contains_key("claude-sonnet-5"));
assert!(cfg.model_roles.contains_key("claude-haiku-4-5")); assert!(cfg.model_roles.contains_key("claude-haiku-4-5"));
} }
@@ -223,10 +223,10 @@ mod tests {
apply_claude_provider( apply_claude_provider(
&mut cfg, &mut cfg,
claude_provider("https://9router.example/v1", Some("sk-new")), claude_provider("https://9router.example/v1", Some("sk-new")),
Some("claude-opus-4-8".to_string()), Some("claude-opus-5".to_string()),
); );
assert_eq!(cfg.default_model, "claude-opus-4-8"); assert_eq!(cfg.default_model, "claude-opus-5");
assert!(cfg.model_roles.contains_key("claude-opus-4-8")); assert!(cfg.model_roles.contains_key("claude-opus-5"));
} }
#[test] #[test]
@@ -130,7 +130,7 @@ pub fn spawn_background_review(
); );
format!( format!(
"{}...\n[diff truncated at {} characters]", "{}...\n[diff truncated at {} characters]",
&diff[..MAX_DIFF_CHARS], crate::utils::truncate_chars(&diff, MAX_DIFF_CHARS),
MAX_DIFF_CHARS MAX_DIFF_CHARS
) )
} else { } else {
+214 -27
View File
@@ -14,7 +14,8 @@ use tracing::{debug, info, instrument};
use crate::llm::provider::LlmClient; use crate::llm::provider::LlmClient;
use crate::subagent::context::SubagentContext; use crate::subagent::context::SubagentContext;
use crate::subagent::division::{tools_for, AccessTier}; use crate::subagent::division::{tools_for, AccessTier};
use crate::tools::{tool_defs, ToolCtx}; use crate::tools::{tool_defs, Tool, ToolCtx};
use serde_json::Value;
use zesdex_domain::agent::progress::AgentProgress; use zesdex_domain::agent::progress::AgentProgress;
use zesdex_domain::core::tool_call::sanitize_tool_arguments; use zesdex_domain::core::tool_call::sanitize_tool_arguments;
use zesdex_domain::core::ChatMessage; use zesdex_domain::core::ChatMessage;
@@ -31,6 +32,95 @@ const TOOL_OUTPUT_MAX_CHARS: usize = 12_000;
/// recovery note steering the model to a different approach. /// recovery note steering the model to a different approach.
const MAX_CONSECUTIVE_TOOL_ERRORS: usize = 3; const MAX_CONSECUTIVE_TOOL_ERRORS: usize = 3;
/// Maximum number of read-only tool calls executed concurrently in a single
/// subagent batch. Read-only tools (read/grep/glob/…) block on disk I/O, so
/// running them on parallel OS threads removes the serial round-trip latency
/// for a batch of independent lookups, mirroring the main turn loop.
const MAX_PARALLEL_TOOLS: usize = 8;
/// Execute a batch of tool calls, running read-only tools concurrently when
/// the whole batch is parallel-safe.
///
/// Returns one `(tool_call_id, tool_name, result)` per call **in the original
/// call order** (OpenAI/Anthropic tool-result ordering contract). `Tool::run`
/// is synchronous, so real parallelism comes from scoped OS threads; `Tool`
/// and `ToolCtx` are `Send + Sync`, so the borrowed references can be shared
/// across the short-lived scoped threads.
///
/// If any single tool in the batch mutates state (edit/write/bash/git/…), the
/// whole batch falls back to the safe sequential path so writes never race.
fn execute_tool_batch(
tools: &[Box<dyn Tool>],
tool_ctx: &ToolCtx,
tool_calls: &[zesdex_domain::core::ToolCall],
) -> Vec<(String, String, String)> {
let parallel = tool_calls.len() > 1
&& tool_calls
.iter()
.all(|tc| crate::tools::tool_is_parallel_safe(&tc.function.name));
if !parallel {
// Sequential fallback (kept identical to the historical behavior).
return tool_calls
.iter()
.map(|tc| {
let tool_name = tc.function.name.clone();
let args = sanitize_tool_arguments(&tc.function.arguments);
let result = run_one_tool(tools, tool_ctx, &tool_name, &args);
(tc.id.clone(), tool_name, result)
})
.collect();
}
// Bounded parallel path: process the batch in windows of
// `MAX_PARALLEL_TOOLS` so concurrency stays bounded, joining each window
// before the next so results stay in original order.
let mut ordered = Vec::with_capacity(tool_calls.len());
for window in tool_calls.chunks(MAX_PARALLEL_TOOLS) {
let window_results = std::thread::scope(|s| {
let handles: Vec<_> = window
.iter()
.map(|tc| {
let tool_name = tc.function.name.clone();
let args = sanitize_tool_arguments(&tc.function.arguments);
s.spawn(move || {
debug!("Subagent executing tool: {tool_name}");
run_one_tool(tools, tool_ctx, &tool_name, &args)
})
})
.collect();
handles
.into_iter()
.map(|h| {
h.join()
.unwrap_or_else(|_| "Error: tool panicked".to_string())
})
.collect::<Vec<_>>()
});
for (tc, result) in window.iter().zip(window_results) {
ordered.push((tc.id.clone(), tc.function.name.clone(), result));
}
}
ordered
}
/// Run a single synchronous tool call and capture its result string.
fn run_one_tool(
tools: &[Box<dyn Tool>],
tool_ctx: &ToolCtx,
tool_name: &str,
args: &Value,
) -> String {
if let Some(tool) = tools.iter().find(|t| t.name() == tool_name) {
match tool.run(tool_ctx, args) {
Ok(output) => output,
Err(e) => format!("Error: {e}"),
}
} else {
format!("Unknown tool: {tool_name}")
}
}
/// Pick a `max_tokens` budget proportional to the directive's length. /// Pick a `max_tokens` budget proportional to the directive's length.
fn adaptive_max_tokens(directive_len: usize) -> u32 { fn adaptive_max_tokens(directive_len: usize) -> u32 {
if directive_len <= 80 { if directive_len <= 80 {
@@ -130,7 +220,7 @@ pub async fn run_agent(
.await?; .await?;
let content = response_msg.content.clone().unwrap_or_default(); 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 no tool calls, we're done — return content
if tool_calls.is_empty() { if tool_calls.is_empty() {
@@ -139,12 +229,18 @@ pub async fn run_agent(
return Ok(content); return Ok(content);
} }
// Execute tool calls // Push the assistant message (with its tool_calls) BEFORE executing
for tc in &tool_calls { // so the tool-calling contract is honoured: tool results reference
let tool_name = &tc.function.name; // the calls declared in the preceding assistant message. Without
let args = sanitize_tool_arguments(&tc.function.arguments); // this, the history is malformed (`[...tool, tool, assistant]`).
messages.push(response_msg);
debug!("Subagent executing tool: {tool_name}"); // 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);
for (id, tool_name, result) in results {
debug!("Subagent tool {tool_name} finished");
report_progress( report_progress(
&tool_ctx, &tool_ctx,
@@ -155,15 +251,6 @@ pub async fn run_agent(
), ),
); );
let result = if let Some(tool) = tools.iter().find(|t| t.name() == tool_name) {
match tool.run(&tool_ctx, &args) {
Ok(output) => output,
Err(e) => format!("Error: {e}"),
}
} else {
format!("Unknown tool: {tool_name}")
};
// Error-recovery: if the same tool keeps failing, inject a // Error-recovery: if the same tool keeps failing, inject a
// system note steering the model to a different approach. // system note steering the model to a different approach.
if result.starts_with("Error:") { if result.starts_with("Error:") {
@@ -171,11 +258,11 @@ pub async fn run_agent(
consecutive_errors += 1; consecutive_errors += 1;
} else { } else {
consecutive_errors = 1; consecutive_errors = 1;
last_tool = tool_name.to_string(); last_tool = tool_name.clone();
} }
if consecutive_errors >= MAX_CONSECUTIVE_TOOL_ERRORS { if consecutive_errors >= MAX_CONSECUTIVE_TOOL_ERRORS {
messages.push(ChatMessage::system( messages.push(ChatMessage::system(
zesdex_domain::agent::prompt::error_recovery_note(tool_name, &result), zesdex_domain::agent::prompt::error_recovery_note(&tool_name, &result),
)); ));
consecutive_errors = 0; consecutive_errors = 0;
} }
@@ -183,15 +270,7 @@ pub async fn run_agent(
consecutive_errors = 0; consecutive_errors = 0;
} }
messages.push(ChatMessage::tool( messages.push(ChatMessage::tool(id, truncate_tool_output(result)));
tc.id.clone(),
truncate_tool_output(result),
));
}
// Add assistant response if there was text content
if !content.is_empty() {
messages.push(ChatMessage::assistant(Some(content)));
} }
} }
@@ -208,3 +287,111 @@ pub async fn run_agent(
"Subagent reached iteration limit ({MAX_ITERATIONS})" "Subagent reached iteration limit ({MAX_ITERATIONS})"
)) ))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolCtxBuilder;
use serde_json::json;
/// A deterministic mock tool whose `run` returns its own name (opting into
/// an optional sleep to make parallel-vs-sequential observable).
struct MockTool {
name: &'static str,
sleep_ms: u64,
}
impl MockTool {
fn new(name: &'static str, sleep_ms: u64) -> Self {
Self { name, sleep_ms }
}
}
impl Tool for MockTool {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"mock tool for tests"
}
fn parameters(&self) -> Value {
json!({"type":"object","properties":{}})
}
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
if self.sleep_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(self.sleep_ms));
}
Ok(self.name.to_string())
}
}
fn tc(name: &str, id: usize) -> zesdex_domain::core::ToolCall {
zesdex_domain::core::ToolCall {
id: format!("call_{id}"),
type_: "function".to_string(),
function: zesdex_domain::core::ToolFunction {
name: name.to_string(),
arguments: serde_json::Value::String(String::new()),
},
}
}
fn ctx() -> ToolCtx {
ToolCtxBuilder::default().build()
}
#[test]
fn parallel_batch_preserves_original_order() {
let tools: Vec<Box<dyn Tool>> = vec![
Box::new(MockTool::new("read", 0)),
Box::new(MockTool::new("grep", 0)),
];
let calls = vec![tc("read", 1), tc("grep", 2), tc("read", 3)];
let results = execute_tool_batch(&tools, &ctx(), &calls);
// Results keep the assistant's original call order.
let names: Vec<&str> = results.iter().map(|(_, n, _)| n.as_str()).collect();
assert_eq!(names, vec!["read", "grep", "read"]);
// IDs follow the same original order (ordering contract).
let ids: Vec<&str> = results.iter().map(|(id, _, _)| id.as_str()).collect();
assert_eq!(ids, vec!["call_1", "call_2", "call_3"]);
}
#[test]
fn parallel_read_batch_is_faster_than_sequential() {
// Both reads sleep 30ms each. Parallel should finish ~30ms (both run
// at once), sequential would take ~60ms.
let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockTool::new("read", 30))];
let calls = vec![tc("read", 1), tc("read", 2)];
let started = std::time::Instant::now();
let results = execute_tool_batch(&tools, &ctx(), &calls);
let elapsed = started.elapsed();
assert_eq!(results.len(), 2);
assert!(
elapsed < std::time::Duration::from_millis(55),
"parallel read batch took {elapsed:?}, expected concurrent execution"
);
assert!(elapsed >= std::time::Duration::from_millis(25));
}
#[test]
fn mutating_tool_forces_sequential_batch() {
// A batch containing a mutating tool ("write") must NOT run in
// parallel — the single 30ms read runs alone, then the write runs.
let tools: Vec<Box<dyn Tool>> = vec![
Box::new(MockTool::new("read", 30)),
Box::new(MockTool::new("write", 0)),
];
let calls = vec![tc("read", 1), tc("write", 2)];
let results = execute_tool_batch(&tools, &ctx(), &calls);
let names: Vec<&str> = results.iter().map(|(_, n, _)| n.as_str()).collect();
assert_eq!(names, vec!["read", "write"]);
let ids: Vec<&str> = results.iter().map(|(id, _, _)| id.as_str()).collect();
assert_eq!(ids, vec!["call_1", "call_2"]);
}
}
+11
View File
@@ -15,6 +15,13 @@ impl InfrastructureToolExecutor {
tools: all_tools(), tools: all_tools(),
} }
} }
/// Whether a tool is read-only and safe to execute concurrently with
/// other parallel-safe tools. Delegates to the registry so the main
/// turn loop and subagent engine share one source of truth.
pub fn is_parallel_safe(tool_name: &str) -> bool {
crate::tools::tool_is_parallel_safe(tool_name)
}
} }
impl ToolExecutor for InfrastructureToolExecutor { impl ToolExecutor for InfrastructureToolExecutor {
@@ -37,4 +44,8 @@ impl ToolExecutor for InfrastructureToolExecutor {
} }
} }
} }
fn is_parallel_safe(&self, tool_name: &str) -> bool {
Self::is_parallel_safe(tool_name)
}
} }
+1 -1
View File
@@ -59,7 +59,7 @@ pub mod workflow;
// like `crate::tools::{Tool, ToolCtx}` continue to work. // like `crate::tools::{Tool, ToolCtx}` continue to work.
pub use context::{ToolCtx, ToolCtxBuilder}; pub use context::{ToolCtx, ToolCtxBuilder};
pub use graduated::{check_graduated_checks, GraduatedCheck}; pub use graduated::{check_graduated_checks, GraduatedCheck};
pub use registry::{all_tools, tool_defs, tool_is_risky}; pub use registry::{all_tools, tool_defs, tool_is_parallel_safe, tool_is_risky};
pub use util::{arg_str, execute_cmd, log_write_edit_tool, resolve_path}; pub use util::{arg_str, execute_cmd, log_write_edit_tool, resolve_path};
/// Common interface every agent-invocable tool implements. /// Common interface every agent-invocable tool implements.
+84
View File
@@ -52,6 +52,34 @@ pub fn tool_is_risky(name: &str) -> bool {
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
} }
/// Whether a tool by name is read-only and therefore safe to run *in
/// parallel* with other tool calls within the same assistant message.
///
/// Read-only tools only inspect the workspace (read files, grep, glob,
/// semantic search, list symbols, recall memory, web search, directory
/// listing). They have no side effects, so concurrent execution cannot
/// create data races or conflicting writes.
///
/// Everything else (edits, writes, deletes, shell, git, planning, memory
/// writes, agent/spawn orchestration) stays sequential to preserve
/// correctness.
pub fn tool_is_parallel_safe(name: &str) -> bool {
matches!(
name,
"read"
| "grep"
| "glob"
| "semantic_search"
| "list_symbols"
| "web_search"
| "recall"
| "dir_list"
| "pong"
| "seq_think"
| "dir_cache_update"
)
}
/// Convert a list of tools into provider-facing `ToolDef` request schema. /// Convert a list of tools into provider-facing `ToolDef` request schema.
pub fn tool_defs(tools: &[Box<dyn super::Tool>]) -> Vec<zesdex_domain::core::ToolDef> { pub fn tool_defs(tools: &[Box<dyn super::Tool>]) -> Vec<zesdex_domain::core::ToolDef> {
tools tools
@@ -66,3 +94,59 @@ pub fn tool_defs(tools: &[Box<dyn super::Tool>]) -> Vec<zesdex_domain::core::Too
}) })
.collect() .collect()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_only_tools_are_parallel_safe() {
for name in [
"read",
"grep",
"glob",
"semantic_search",
"list_symbols",
"web_search",
"recall",
"dir_list",
"pong",
"seq_think",
"dir_cache_update",
] {
assert!(
tool_is_parallel_safe(name),
"{name} should be parallel-safe"
);
}
}
#[test]
fn mutating_and_shell_tools_are_not_parallel_safe() {
for name in [
"edit",
"write",
"delete",
"bash",
"git_operator",
"git_worktree",
"remember",
"forget",
"todowrite",
"todofinish",
"plan_enter",
"plan_ready",
"workflow_run",
"hive_mind",
"spawn_agents",
"spawn_pipeline",
"parallel_delegate",
"explore_codebase",
] {
assert!(
!tool_is_parallel_safe(name),
"{name} should NOT be parallel-safe"
);
}
}
}
+60 -3
View File
@@ -221,7 +221,14 @@ pub fn build_rich_context(root: &Path) -> String {
)); ));
// 2. Custom Rules // 2. Custom Rules
let rule_files = [".cursorrules", ".zesdexrules", "claude.md", "agent.md"]; let rule_files = [
"AGENTS.md",
"CLAUDE.md",
".cursorrules",
".zesdexrules",
"claude.md",
"agent.md",
];
for file in rule_files { for file in rule_files {
let p = root.join(file); let p = root.join(file);
if let Ok(content) = std::fs::read_to_string(&p) { if let Ok(content) = std::fs::read_to_string(&p) {
@@ -268,7 +275,7 @@ pub fn build_rich_context(root: &Path) -> String {
let p = root.join(file); let p = root.join(file);
if let Ok(content) = std::fs::read_to_string(&p) { if let Ok(content) = std::fs::read_to_string(&p) {
let snippet = if content.len() > 1500 { let snippet = if content.len() > 1500 {
format!("{}\n... (truncated)", &content[..1500]) format!("{}\n... (truncated)", truncate_chars(&content, 1500))
} else { } else {
content content
}; };
@@ -294,7 +301,7 @@ pub fn build_rich_context(root: &Path) -> String {
let readme_path = root.join("README.md"); let readme_path = root.join("README.md");
if let Ok(content) = std::fs::read_to_string(&readme_path) { if let Ok(content) = std::fs::read_to_string(&readme_path) {
let snippet = if content.len() > 1000 { let snippet = if content.len() > 1000 {
format!("{}\n... (truncated)", &content[..1000]) format!("{}\n... (truncated)", truncate_chars(&content, 1000))
} else { } else {
content content
}; };
@@ -341,3 +348,53 @@ pub fn build_rich_context(root: &Path) -> String {
ctx.trim_end().to_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 -3
View File
@@ -316,13 +316,13 @@ fn handle_submit_input(state: &mut AppStateRest, text: String) {
in_flight: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), in_flight: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
abort: state.abort_flag.clone(), abort: state.abort_flag.clone(),
api_key: api_key.clone(), api_key: api_key.clone(),
model: state.settings.model.clone(), model: zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config),
api_base: provider_cfg.as_ref().map(|cfg| cfg.api_base.clone()), api_base: provider_cfg.as_ref().map(|cfg| cfg.api_base.clone()),
}; };
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new( let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
api_key, api_key,
state.settings.model.clone(), zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config),
provider_cfg.map(|cfg| cfg.api_base.clone()), provider_cfg.map(|cfg| cfg.api_base.clone()),
)); ));
@@ -465,7 +465,7 @@ fn handle_compact(state: &mut AppStateRest) {
.get(provider_name) .get(provider_name)
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
let model = state.settings.model.clone(); let model = zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config);
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone()); let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base); let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base);
+1 -1
View File
@@ -80,7 +80,7 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
// ── Resolve provider configuration ───────────────────────────────── // ── Resolve provider configuration ─────────────────────────────────
let provider_name = &state.settings.provider; let provider_name = &state.settings.provider;
let api_key = resolve_api_key(state, provider_name); let api_key = resolve_api_key(state, provider_name);
let model = state.settings.model.clone(); let model = zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config);
let api_base = resolve_api_base(state, provider_name); let api_base = resolve_api_base(state, provider_name);
// ── Build message list ───────────────────────────────────────────── // ── Build message list ─────────────────────────────────────────────