//! Auto-review engine — after edits, reviews AND auto-fixes issues using //! tools + LLM, all synchronously in a background thread (no tokio runtime //! needed). //! //! Flow: after an agent turn with edits completes: //! 1. Emit `WorkflowAgentUpdate(Running)` → visible in workflow sidebar //! 2. Run `git diff` to get the changed files //! 3. Call LLM with the diff to identify issues and suggested fixes //! 4. Apply fixes using sync tools (edit/write) //! 5. Results stream as `TurnEvent::SystemNote` events //! 6. Emit `WorkflowAgentUpdate(Completed)` when done use std::collections::VecDeque; use std::path::PathBuf; use std::process::Command; use std::sync::{Arc, Mutex}; use tracing::{debug, info, instrument, warn}; use crate::llm::provider::LlmClient; use crate::subagent::division::{tools_for, AccessTier}; use crate::tools::{Tool, ToolCtx}; use crate::{AgentStatus, TurnEvent}; use zesdex_domain::core::ChatMessage; const REVIEW_AGENT_ID: &str = "auto-review"; /// Spawn a background task that reviews changes and auto-fixes issues. /// /// Runs asynchronously using tokio::spawn. #[instrument(skip(turn_events))] pub fn spawn_background_review( workspace_roots: Vec, turn_events: Arc>>, api_key: String, model: String, api_base: Option, ) { let agent_id = REVIEW_AGENT_ID.to_string(); let agent_name = "Auto-Review".to_string(); tokio::spawn(async move { let root = match workspace_roots.first() { Some(r) => r.clone(), None => { debug!("auto-review: no workspace root, skipping"); return; } }; info!("auto-review: starting"); // Mark Running in workflow panel push_event( &turn_events, TurnEvent::WorkflowAgentUpdate { agent_id: agent_id.clone(), agent_name: agent_name.clone(), status: AgentStatus::Running, }, ); // 1. Get the git diff to know what changed let diff = match get_git_diff(&root) { Ok(d) if !d.is_empty() => d, Ok(_) => { debug!("auto-review: no changes detected"); push_event( &turn_events, TurnEvent::WorkflowAgentUpdate { agent_id, agent_name, status: AgentStatus::Completed, }, ); return; } Err(e) => { debug!(error = %e, "auto-review: git diff failed"); push_event( &turn_events, TurnEvent::WorkflowAgentUpdate { agent_id, agent_name, status: AgentStatus::Failed(e), }, ); return; } }; push_event( &turn_events, TurnEvent::SystemNote { kind: "review".into(), message: "🔍 Auto-review: examining and fixing issues...".into(), }, ); // 2. Build tool context + load write-tier tools let tool_ctx = ToolCtx::builder() .session_dir(root.join(".zesdex").join("sessions").join("auto-review")) .workspaces(workspace_roots.clone()) .turn_events(turn_events.clone()) .build(); let tools = tools_for(&AccessTier::Write); // 3. Resolve LLM credentials let base_url = api_base.unwrap_or_else(|| { std::env::var("OPENAI_API_BASE") .unwrap_or_else(|_| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()) }); let client = LlmClient::new(api_key, model, Some(base_url)); // 4. Truncate diff if needed const MAX_DIFF_CHARS: usize = 5000; let truncated_diff = if diff.len() > MAX_DIFF_CHARS { push_event( &turn_events, TurnEvent::SystemNote { kind: "review".into(), message: format!( "📐 Diff is large ({} chars), reviewing first {} chars...", diff.len(), MAX_DIFF_CHARS ), }, ); format!( "{}...\n[diff truncated at {} characters]", &diff[..MAX_DIFF_CHARS], MAX_DIFF_CHARS ) } else { diff.to_string() }; // 5. Call LLM to review the diff and suggest fixes. let system_msg = ChatMessage::system( "You are an auto-review subagent. Your ONLY job:\n\ 1. Review the git diff below for:\n\ - Typos and spelling errors\n\ - Missing imports or undefined references\n\ - Syntax errors or type mismatches\n\ - Logic bugs or off-by-one errors\n\ - Missing error handling\n\ - Security issues\n\n\ 2. For each issue found, output a command to fix it.\n\n\ Available commands:\n\ - `edit ` then provide the old text and new text\n\ - `write ` then provide the new content\n\n\ Output format:\n\ If no issues: NO_ISSUES_FOUND\n\n\ If issues found:\n\ ---\n\ FILE: \n\ ISSUE: \n\ SEVERITY: HIGH|MEDIUM|LOW\n\ OLD: \n\ NEW: \n\ ---".to_string(), ); let user_msg = ChatMessage::user(format!( "Review and fix this git diff:\n\n```diff\n{truncated_diff}\n```" )); let response = run_llm_review(&client, &[system_msg, user_msg]).await; let response_text = match response { Ok(text) => text, Err(e) => { warn!(error = %e, "auto-review LLM call failed"); push_event( &turn_events, TurnEvent::SystemNote { kind: "review".into(), message: format!("⚠️ Auto-review failed: {e}"), }, ); push_event( &turn_events, TurnEvent::WorkflowAgentUpdate { agent_id, agent_name, status: AgentStatus::Failed(e.to_string()), }, ); return; } }; // 6. Parse and apply fixes if response_text.trim() == "NO_ISSUES_FOUND" || response_text.trim().is_empty() { push_event( &turn_events, TurnEvent::SystemNote { kind: "review".into(), message: "✅ Auto-review: no issues found.".into(), }, ); info!("auto-review: no issues found"); } else { // Try to apply structured fixes let fix_count = apply_fixes_from_response(&response_text, &tools, &tool_ctx); push_event( &turn_events, TurnEvent::SystemNote { kind: "review_finding".into(), message: format!("📋 Auto-review complete ({} fix(es) applied).\n{}", fix_count, response_text.trim()), }, ); info!(fix_count, "auto-review: completed with fixes"); } push_event( &turn_events, TurnEvent::WorkflowAgentUpdate { agent_id, agent_name, status: AgentStatus::Completed, }, ); }); } /// Run the LLM review call asynchronously using ProviderService. async fn run_llm_review(client: &LlmClient, messages: &[ChatMessage]) -> Result { use zesdex_application::ports::ProviderService; match client.chat(messages, None, Some(1024), Some(0.3)).await { Ok((msg, _)) => Ok(msg.content.unwrap_or_default()), Err(e) => Err(e.to_string()), } } /// Parse the LLM response for structured fix commands and apply them. fn apply_fixes_from_response( response: &str, tools: &[Box], tool_ctx: &ToolCtx, ) -> usize { let mut fix_count = 0; // Parse structured fix blocks let blocks: Vec<&str> = response.split("---").collect(); for block in &blocks { let trimmed = block.trim(); if trimmed.is_empty() { continue; } let lines: Vec<&str> = trimmed.lines().map(|l| l.trim()).collect(); if lines.len() < 4 { continue; } // Try to extract structured fix let file_path = extract_field(&lines, "FILE:").unwrap_or(""); let severity = extract_field(&lines, "SEVERITY:").unwrap_or("LOW"); let old_text = extract_field(&lines, "OLD:").unwrap_or(""); let new_text = extract_field(&lines, "NEW:").unwrap_or(""); if file_path.is_empty() || old_text.is_empty() || new_text.is_empty() { continue; } // Only auto-fix HIGH and MEDIUM severity issues if severity != "HIGH" && severity != "MEDIUM" { debug!(severity, file = file_path, "skipping LOW severity fix"); continue; } // Try to apply the fix using the edit tool if let Some(edit_tool) = tools.iter().find(|t| t.name() == "edit") { let args = serde_json::json!({ "path": file_path, "old": old_text, "new": new_text, "reason": "auto-review fix" }); match edit_tool.run(tool_ctx, &args) { Ok(result) => { info!(file = file_path, "auto-review fix applied: {result}"); fix_count += 1; } Err(e) => { debug!(file = file_path, error = %e, "auto-review fix failed"); } } } } fix_count } /// Extract a field value from parsed lines (e.g. "FILE: src/main.rs" → "src/main.rs"). fn extract_field<'a>(lines: &[&'a str], prefix: &str) -> Option<&'a str> { for line in lines { if let Some(val) = line.strip_prefix(prefix) { let trimmed = val.trim(); if !trimmed.is_empty() { return Some(trimmed); } } } None } /// Run `git diff` to get workspace changes (both staged and unstaged). fn get_git_diff(workspace_root: &PathBuf) -> Result { let git_dir = workspace_root.join(".git"); if !git_dir.exists() { return Err("not a git repository".to_string()); } let mut combined = String::new(); // Unstaged diff if let Ok(out) = Command::new("git") .args(["diff"]) .current_dir(workspace_root) .output() { let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); if !stdout.is_empty() { combined.push_str("=== Unstaged Changes ===\n"); combined.push_str(&stdout); combined.push('\n'); } } // Staged diff if let Ok(out) = Command::new("git") .args(["diff", "--cached"]) .current_dir(workspace_root) .output() { let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); if !stdout.is_empty() { combined.push_str("=== Staged Changes ===\n"); combined.push_str(&stdout); combined.push('\n'); } } if combined.is_empty() { return Err("no changes".to_string()); } Ok(combined) } /// Push a TurnEvent onto the shared event queue. fn push_event(queue: &Arc>>, event: TurnEvent) { if let Ok(mut q) = queue.lock() { q.push_back(event); } }