Files
zesdex/apps/infrastructure/src/subagent/auto/engine.rs
T

364 lines
12 KiB
Rust
Raw Normal View History

//! 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<PathBuf>,
turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
api_key: String,
model: String,
api_base: Option<String>,
) {
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 <file>` then provide the old text and new text\n\
- `write <file>` then provide the new content\n\n\
Output format:\n\
If no issues: NO_ISSUES_FOUND\n\n\
If issues found:\n\
---\n\
FILE: <path>\n\
ISSUE: <description>\n\
SEVERITY: HIGH|MEDIUM|LOW\n\
OLD: <exact text to replace>\n\
NEW: <replacement text>\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<String, String> {
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<dyn Tool>],
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<String, String> {
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<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
if let Ok(mut q) = queue.lock() {
q.push_back(event);
}
}