From 21e3ccc891ab886044a5f0a770db710769d1287b Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 28 Aug 2026 09:50:16 +0700 Subject: [PATCH] feat(agent): subagent tool paralel + auto-load AGENTS.md + verify cek setelah edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Cargo.lock | 22 +- apps/application/src/agent/turn_service.rs | 52 ++++- apps/domain/src/agent/prompt.rs | 52 +++++ apps/domain/src/lib.rs | 5 +- apps/infrastructure/src/subagent/engine.rs | 228 +++++++++++++++++++-- apps/infrastructure/src/utils.rs | 9 +- 6 files changed, 332 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a5eef8..a351b20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4862,7 +4862,7 @@ dependencies = [ [[package]] name = "zesdex-api" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "argon2", @@ -4885,7 +4885,7 @@ dependencies = [ [[package]] name = "zesdex-application" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "base64", @@ -4903,7 +4903,7 @@ dependencies = [ [[package]] name = "zesdex-bootstrap" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "chrono", @@ -4920,7 +4920,7 @@ dependencies = [ [[package]] name = "zesdex-daemon" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "base64", @@ -4944,7 +4944,7 @@ dependencies = [ [[package]] name = "zesdex-domain" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "base64", @@ -4960,7 +4960,7 @@ dependencies = [ [[package]] name = "zesdex-gateway" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "axum", @@ -4987,7 +4987,7 @@ dependencies = [ [[package]] name = "zesdex-grpc" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "axum", @@ -5004,7 +5004,7 @@ dependencies = [ [[package]] name = "zesdex-infrastructure" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "argon2", @@ -5052,7 +5052,7 @@ dependencies = [ [[package]] name = "zesdex-tui" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "base64", @@ -5078,7 +5078,7 @@ dependencies = [ [[package]] name = "zesdex-web" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "axum", @@ -5098,7 +5098,7 @@ dependencies = [ [[package]] name = "zesdex-ws" -version = "1.19.4" +version = "1.19.5" dependencies = [ "anyhow", "axum", diff --git a/apps/application/src/agent/turn_service.rs b/apps/application/src/agent/turn_service.rs index 431e5ca..23ff9a1 100644 --- a/apps/application/src/agent/turn_service.rs +++ b/apps/application/src/agent/turn_service.rs @@ -5,7 +5,7 @@ use tracing::{debug, info, warn}; use zesdex_domain::agent::{AgentTurnParams, TurnEvent}; 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 crate::ports::ProviderService; @@ -111,6 +111,46 @@ fn conversation_chars(messages: &[ChatMessage]) -> usize { .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::() + + "\n...[project context truncated]" +} + /// Track repeated tool-call errors so the loop can recover instead of /// burning iterations retrying the same failing tool. #[derive(Default)] @@ -341,9 +381,17 @@ impl super::AgentTurnService for AgentTurnS // Insert system prompt at position 0 once and keep it there for the // 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 .messages - .insert(0, ChatMessage::system(main_agent_prompt())); + .insert(0, ChatMessage::system(system_prompt)); let original_count = params.messages.len(); // Estimate request complexity from the last user message. diff --git a/apps/domain/src/agent/prompt.rs b/apps/domain/src/agent/prompt.rs index 1fb9cdc..3ff43ce 100644 --- a/apps/domain/src/agent/prompt.rs +++ b/apps/domain/src/agent/prompt.rs @@ -41,10 +41,43 @@ architectural plans and `todowrite` to maintain granular task checklists. 4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \ 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." .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. /// /// The directive is embedded in a system message that also communicates the @@ -119,6 +152,25 @@ mod tests { 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] fn subagent_directive_includes_directive_text() { let prompt = subagent_directive("test directive", "/home", "/home/project"); diff --git a/apps/domain/src/lib.rs b/apps/domain/src/lib.rs index 6a8e4a9..4a4cef3 100644 --- a/apps/domain/src/lib.rs +++ b/apps/domain/src/lib.rs @@ -58,6 +58,9 @@ pub use agent::*; // Sub-module items need explicit re-exports pub use agent::defaults::*; 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 workflow::*; diff --git a/apps/infrastructure/src/subagent/engine.rs b/apps/infrastructure/src/subagent/engine.rs index 7b98ddc..b1af8a9 100644 --- a/apps/infrastructure/src/subagent/engine.rs +++ b/apps/infrastructure/src/subagent/engine.rs @@ -14,7 +14,8 @@ use tracing::{debug, info, instrument}; use crate::llm::provider::LlmClient; use crate::subagent::context::SubagentContext; 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::core::tool_call::sanitize_tool_arguments; 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. 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], + 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::>() + }); + 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], + 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. fn adaptive_max_tokens(directive_len: usize) -> u32 { if directive_len <= 80 { @@ -139,12 +229,12 @@ pub async fn run_agent( return Ok(content); } - // Execute tool calls - for tc in &tool_calls { - let tool_name = &tc.function.name; - let args = sanitize_tool_arguments(&tc.function.arguments); + // 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); - debug!("Subagent executing tool: {tool_name}"); + for (id, tool_name, result) in results { + debug!("Subagent tool {tool_name} finished"); report_progress( &tool_ctx, @@ -155,15 +245,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 // system note steering the model to a different approach. if result.starts_with("Error:") { @@ -171,11 +252,11 @@ pub async fn run_agent( consecutive_errors += 1; } else { consecutive_errors = 1; - last_tool = tool_name.to_string(); + last_tool = tool_name.clone(); } if consecutive_errors >= MAX_CONSECUTIVE_TOOL_ERRORS { 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; } @@ -183,10 +264,7 @@ pub async fn run_agent( consecutive_errors = 0; } - messages.push(ChatMessage::tool( - tc.id.clone(), - truncate_tool_output(result), - )); + messages.push(ChatMessage::tool(id, truncate_tool_output(result))); } // Add assistant response if there was text content @@ -208,3 +286,111 @@ pub async fn run_agent( "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 { + 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> = 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> = 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> = 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"]); + } +} diff --git a/apps/infrastructure/src/utils.rs b/apps/infrastructure/src/utils.rs index 53ffe49..e87c96f 100644 --- a/apps/infrastructure/src/utils.rs +++ b/apps/infrastructure/src/utils.rs @@ -221,7 +221,14 @@ pub fn build_rich_context(root: &Path) -> String { )); // 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 { let p = root.join(file); if let Ok(content) = std::fs::read_to_string(&p) {