Files
zesdex/src/app/subagent/auto.rs
T

520 lines
18 KiB
Rust

//! Auto-subagent orchestration: the main agent automatically delegates
//! review, test-generation, architecture-review, and security-review tasks
//! to subagents without requiring explicit tool calls from the LLM.
//!
//! Two modes:
//! - **Inline** (`spawn_quick_review`): runs synchronously within the turn
//! after each write/edit tool call. Results are fed back into the LLM
//! conversation so the agent can act on feedback immediately.
//! - **Background** (`spawn_background_*`): runs asynchronously on a
//! dedicated OS thread at the end of a turn. Reports results via
//! `TurnEvent::SystemNote`, consumed by the TUI on the next Tick.
//!
//! Why inline vs background:
//! - Inline reviews give the agent an immediate feedback loop ("I just
//! wrote this file, let me check if it's correct before continuing").
//! - Background reviews catch broader concerns (missing tests, architectural
//! drift, security issues) without blocking the main agent's flow.
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::collections::VecDeque;
use crate::app::state::runtime::TurnEvent;
use crate::app::subagent::context::build_subagent_context;
use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition;
use crate::app::subagent::event::SubagentEvent;
/// File extensions that should not trigger auto-review (config, lock, data).
const SKIP_REVIEW_EXTENSIONS: &[&str] = &[
".lock", ".md", ".txt", ".json", ".toml", ".yaml", ".yml",
".svg", ".png", ".jpg", ".ico", ".woff", ".woff2",
];
/// File names that should not trigger auto-review.
const SKIP_REVIEW_FILES: &[&str] = &[
"Cargo.lock", "yarn.lock", "package-lock.json",
".gitignore", ".env", ".env.example",
];
/// Prevents a second background subagent of the same kind from spawning
/// while one is already in flight. Without this, a chatty multi-turn edit
/// session could stack overlapping test-gen/arch/security reviews of
/// overlapping file sets, none of which could be told apart in the
/// `SystemNote` toast stream.
static TEST_GEN_RUNNING: AtomicBool = AtomicBool::new(false);
static ARCH_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false);
static SECURITY_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false);
/// ─── Helpers ───
///
/// Check whether a file path is worth auto-reviewing (not config/lock/data).
///
/// Vendored/generated directories are matched by path *segment* rather than
/// a `/target/`-style substring check — the substring form misses paths
/// where the directory is the first component (e.g. `target/debug/build.rs`,
/// which has no leading slash), the same class of bug fixed in
/// `is_production_code` below.
pub fn is_reviewable_path(path: &str) -> bool {
let lower = path.to_lowercase();
if SKIP_REVIEW_FILES.iter().any(|f| lower.ends_with(f)) {
return false;
}
if SKIP_REVIEW_EXTENSIONS.iter().any(|e| lower.ends_with(e)) {
return false;
}
// Skip paths that are clearly generated or vendored
let in_vendored_dir = std::path::Path::new(&lower).components().any(|c| {
matches!(
c,
std::path::Component::Normal(seg)
if matches!(seg.to_str(), Some("target" | "node_modules" | ".git" | "vendor"))
)
});
if in_vendored_dir {
return false;
}
true
}
/// Determine whether a file change looks like it modifies production logic
/// (vs. tests, config, or documentation) — used to decide if a test-gen
/// or security-review background subagent should fire.
///
/// Matches test-ness by path *segment* (a directory literally named
/// "test"/"tests"/"__tests__") or by filename convention
/// (`foo_test.rs`, `foo.test.ts`, `test_foo.py`, `foo_spec.rb`), not by a
/// raw substring check — a plain `.contains("test")` would wrongly exclude
/// legitimate production files like `src/attestation.rs` or
/// `src/latest/foo.rs`.
fn is_production_code(path: &str) -> bool {
let lower = path.to_lowercase();
let path_obj = std::path::Path::new(&lower);
let in_test_dir = path_obj.components().any(|c| {
matches!(
c,
std::path::Component::Normal(seg)
if matches!(seg.to_str(), Some("test") | Some("tests") | Some("__tests__"))
)
});
let file_stem = path_obj.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let is_test_filename = file_stem.starts_with("test_")
|| file_stem.ends_with("_test")
|| file_stem.ends_with(".test")
|| file_stem == "spec"
|| file_stem.ends_with("_spec")
|| file_stem.ends_with(".spec");
if in_test_dir || is_test_filename {
return false;
}
// Only source files — use Path::extension() to avoid clippy
// case_sensitive_file_extension_comparisons lint
path_obj
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
matches!(
ext,
"rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift"
| "c" | "cpp" | "h" | "hpp"
)
})
}
/// ─── Inline Quick Review (synchronous, feeds back to LLM) ───
///
/// Spawn a lightweight inline code review subagent for the given file.
///
/// The subagent reads the file (read-only), checks for common issues,
/// and returns a concise text verdict. This runs synchronously so the
/// main agent's `run_agent_turn` can inject the result back into the
/// LLM conversation for immediate action.
///
/// Returns `Ok(verdict)` if the review completed, or an error if the
/// subagent could not be spawned or failed internally. Callers should
/// log and swallow errors gracefully — a failed inline review should
/// never interrupt the main agent's flow.
pub fn spawn_quick_review(
file_path: &str,
session_dir: &Path,
workspaces: &[std::path::PathBuf],
) -> anyhow::Result<String> {
let prompt = format!(
"{}\n\nFile to review: {}",
crate::resources::AUTO_REVIEWER_PROMPT,
file_path,
);
let def = AgentDefinition::new(
"quick-reviewer".to_string(),
"reviewer".to_string(),
)
.with_system_prompt(prompt);
let mut ctx = build_subagent_context(&def);
ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec();
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() {
match &event {
SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[auto-review] tool call: {}", tool);
}
SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[auto-review] tool result: {}", tool);
}
SubagentEvent::Completed { .. } => {
tracing::debug!("[auto-review] completed");
}
_ => {}
}
}
});
let verdict = run_subagent(&ctx, &tx)?;
tracing::info!(
"[auto-review] quick review for '{}': {}",
file_path,
verdict.lines().next().unwrap_or(&verdict),
);
Ok(verdict)
}
/// ─── Background Subagent Spawners (async, report via `SystemNote`) ───
///
/// Run a subagent built from `def`, retrying once if the first attempt
/// fails. Background subagents call this instead of running once and
/// silently swallowing the error into a note string, so a single transient
/// LLM/tool failure doesn't just disappear.
///
/// Return: `Ok(output)` if either attempt succeeded, `Err(message)`
/// describing the final failure if both attempts failed.
fn run_subagent_with_retry(
def: &AgentDefinition,
session_dir: &Path,
workspaces: &[std::path::PathBuf],
label: &str,
abort_flag: Option<&Arc<AtomicBool>>,
) -> Result<String, String> {
let mut last_err = String::new();
for attempt in 1..=2 {
if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) {
return Err("aborted by user".to_string());
}
let mut ctx = build_subagent_context(def);
ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec();
ctx.abort_flag = abort_flag.cloned();
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let drain_label = label.to_string();
let _drain = std::thread::spawn(move || {
while let Some(event) = rx.blocking_recv() {
if let SubagentEvent::StepFailed { step, error } = &event {
tracing::warn!("[{drain_label}] step {step} failed: {error}");
}
}
});
match run_subagent(&ctx, &tx) {
Ok(output) => return Ok(output),
Err(e) => {
tracing::warn!("[{label}] attempt {attempt}/2 failed: {e}");
last_err = e.to_string();
}
}
}
Err(format!("failed after 2 attempts: {last_err}"))
}
/// Spawn a background subagent that generates tests for modified files.
///
/// Uses the test-generator prompt and has read-write access so it can
/// create test files. Runs in a separate OS thread and reports completion
/// via `TurnEvent::SystemNote { kind: "bg-test-gen" }`.
pub fn spawn_background_test_gen(
file_paths: &[String],
session_dir: &Path,
workspaces: &[std::path::PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
if file_paths.is_empty() {
return;
}
if TEST_GEN_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
tracing::debug!("[bg-test-gen] skipped — a test-gen run is already in flight");
return;
}
let paths = file_paths.to_vec();
let sd = session_dir.to_path_buf();
let ws = workspaces.to_vec();
let events = turn_events.clone();
std::thread::spawn(move || {
tracing::info!(
"[bg-test-gen] spawning for {} file(s): {:?}",
paths.len(),
paths,
);
let file_list = paths.join("\n");
let prompt = format!(
"{}\n\nModified files that need tests:\n{}",
crate::resources::TEST_GENERATOR_PROMPT,
file_list,
);
let def = AgentDefinition::new(
"test-generator".to_string(),
"coder".to_string(), // needs write access
)
.with_system_prompt(prompt)
;
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen", Some(&abort_flag));
let message = match &result {
Ok(output) => {
let first = output.lines().next().unwrap_or(output);
format!("Auto test-gen: {first}")
}
Err(e) if e.contains("aborted") => format!("Auto test-gen cancelled: {e}"),
Err(e) => format!("ESCALATED: Auto test-gen {e}"),
};
if let Ok(mut q) = events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "bg-test-gen".to_string(),
message,
});
}
TEST_GEN_RUNNING.store(false, Ordering::SeqCst);
});
}
/// Spawn a background architecture-review subagent.
///
/// Inspects the modified files for architectural consistency (layering,
/// coupling, module boundaries). Reports via
/// `TurnEvent::SystemNote { kind: "bg-arch-review" }`.
pub fn spawn_background_arch_review(
file_paths: &[String],
session_dir: &Path,
workspaces: &[std::path::PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
if file_paths.is_empty() {
return;
}
if ARCH_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
tracing::debug!("[bg-arch-review] skipped — an arch-review run is already in flight");
return;
}
let paths = file_paths.to_vec();
let sd = session_dir.to_path_buf();
let ws = workspaces.to_vec();
let events = turn_events.clone();
std::thread::spawn(move || {
let file_list = paths.join("\n");
let prompt = format!(
"{}\n\nModified files for architecture review:\n{}",
crate::resources::ARCH_REVIEWER_PROMPT,
file_list,
);
let def = AgentDefinition::new(
"arch-reviewer".to_string(),
"reviewer".to_string(),
)
.with_system_prompt(prompt)
;
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-arch-review", Some(&abort_flag));
let message = match &result {
Ok(output) => {
let first = output.lines().next().unwrap_or(output);
format!("Architecture review: {first}")
}
Err(e) if e.contains("aborted") => format!("Architecture review cancelled: {e}"),
Err(e) => format!("ESCALATED: Architecture review {e}"),
};
if let Ok(mut q) = events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "bg-arch-review".to_string(),
message,
});
}
ARCH_REVIEW_RUNNING.store(false, Ordering::SeqCst);
});
}
/// Spawn a background security-review subagent.
///
/// Checks modified files for security vulnerabilities. Reports via
/// `TurnEvent::SystemNote { kind: "bg-security-review" }`.
pub fn spawn_background_security_review(
file_paths: &[String],
session_dir: &Path,
workspaces: &[std::path::PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
if file_paths.is_empty() {
return;
}
// Only review production code files for security — test files and
// config files are out of scope for security review.
let prod_paths: Vec<String> = file_paths
.iter()
.filter(|p| is_production_code(p))
.cloned()
.collect();
if prod_paths.is_empty() {
return;
}
if SECURITY_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
tracing::debug!("[bg-security-review] skipped — a security-review run is already in flight");
return;
}
let paths = prod_paths;
let sd = session_dir.to_path_buf();
let ws = workspaces.to_vec();
let events = turn_events.clone();
std::thread::spawn(move || {
let file_list = paths.join("\n");
let prompt = format!(
"{}\n\nModified files for security review:\n{}",
crate::resources::SECURITY_REVIEWER_PROMPT,
file_list,
);
let def = AgentDefinition::new(
"security-reviewer".to_string(),
"reviewer".to_string(),
)
.with_system_prompt(prompt)
;
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-security-review", Some(&abort_flag));
let message = match &result {
Ok(output) => {
let first = output.lines().next().unwrap_or(output);
format!("Security review: {first}")
}
Err(e) if e.contains("aborted") => format!("Security review cancelled: {e}"),
Err(e) => format!("ESCALATED: Security review {e}"),
};
if let Ok(mut q) = events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "bg-security-review".to_string(),
message,
});
}
SECURITY_REVIEW_RUNNING.store(false, Ordering::SeqCst);
});
}
/// Convenience: spawn all applicable background subagents for a set of edited
/// file paths. Called once at the end of a main agent turn.
///
/// Flow: always spawns arch-review and security-review if there are
/// reviewable production files → spawns test-gen only if there are source
/// files that aren't already tests.
pub fn spawn_all_background(
file_paths: &[String],
session_dir: &Path,
workspaces: &[std::path::PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
abort_flag: Arc<AtomicBool>,
) {
if file_paths.is_empty() {
return;
}
// Background test-gen: only for non-test source files
let source_paths: Vec<String> = file_paths
.iter()
.filter(|p| is_production_code(p))
.cloned()
.collect();
spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events, abort_flag.clone());
// Background arch review: for all files that are reviewable
let reviewable: Vec<String> = file_paths
.iter()
.filter(|p| is_reviewable_path(p))
.cloned()
.collect();
spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events, abort_flag.clone());
// Background security review: only production source files
spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events, abort_flag);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reviewable_path_skips_lockfiles_and_known_extensions() {
assert!(!is_reviewable_path("Cargo.lock"));
assert!(!is_reviewable_path("package.json"));
assert!(!is_reviewable_path("logo.svg"));
}
#[test]
fn reviewable_path_skips_vendored_and_generated_dirs() {
assert!(!is_reviewable_path("target/debug/build.rs"));
assert!(!is_reviewable_path("node_modules/foo/index.js"));
}
#[test]
fn reviewable_path_accepts_ordinary_source_files() {
assert!(is_reviewable_path("src/main.rs"));
}
#[test]
fn production_code_excludes_dedicated_test_directories() {
assert!(!is_production_code("src/tests/foo.rs"));
assert!(!is_production_code("__tests__/baz.test.ts"));
}
#[test]
fn production_code_excludes_test_filename_conventions() {
assert!(!is_production_code("src/foo_test.rs"));
assert!(!is_production_code("src/test_foo.py"));
assert!(!is_production_code("src/foo.spec.ts"));
}
#[test]
fn production_code_does_not_false_positive_on_substring_test() {
// Regression: a plain `.contains("test")` would wrongly exclude
// these legitimate production files.
assert!(is_production_code("src/attestation.rs"));
assert!(is_production_code("src/latest/foo.rs"));
}
#[test]
fn production_code_requires_known_source_extension() {
assert!(!is_production_code("README.md"));
assert!(is_production_code("src/main.rs"));
}
}