306 lines
10 KiB
Rust
306 lines
10 KiB
Rust
//! Auto-review engine — after edits, spawns a subagent that reviews AND
|
|
//! automatically fixes issues using tool access (edit/write/grep).
|
|
//!
|
|
//! 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. Spawn the subagent engine with Write-tier tools + directive to
|
|
//! review & fix
|
|
//! 4. The subagent finds issues and applies fixes using edit/write tools
|
|
//! 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::subagent::context::SubagentContext;
|
|
use crate::subagent::division::AccessTier;
|
|
use crate::subagent::engine::run_agent;
|
|
use crate::tools::ToolCtx;
|
|
use crate::{AgentStatus, TurnEvent};
|
|
|
|
const REVIEW_AGENT_ID: &str = "auto-review";
|
|
|
|
/// Spawn a review subagent that reviews changes and auto-fixes issues.
|
|
///
|
|
/// The subagent runs inline on the current background thread (no extra
|
|
/// thread spawn) with its own tokio runtime and Write-tier tool access
|
|
/// (edit, write, grep, read, glob). It receives the git diff as context
|
|
/// and is directed to:
|
|
/// 1. Read changed files
|
|
/// 2. Check for typos, missing imports, syntax errors, logic bugs
|
|
/// 3. Fix any issues found using edit/write tools
|
|
/// 4. Report what was fixed
|
|
///
|
|
/// All findings stream as TurnEvent events consumed by the TUI event loop.
|
|
#[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();
|
|
|
|
std::thread::spawn(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;
|
|
}
|
|
};
|
|
|
|
// Truncate very large diffs for the prompt
|
|
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()
|
|
};
|
|
|
|
// 2. Build directive: review AND fix issues using tools
|
|
let directive = format!(
|
|
"You are an auto-review subagent. Complete the following:\n\n\
|
|
1. Review this git diff 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. FIX any issues you find using the available tools:\n\
|
|
- `read` to check file contents\n\
|
|
- `edit` to fix specific text blocks\n\
|
|
- `write` to replace files if needed\n\
|
|
- `grep` to find related patterns\n\n\
|
|
3. Be conservative: only fix CLEAR, CONFIRMED issues. \
|
|
Don't change logic, style, or formatting.\n\
|
|
4. Report what you fixed at the end.\n\n\
|
|
Git diff of changes:\n\n```diff\n{truncated_diff}\n```"
|
|
);
|
|
|
|
// 3. Emit progress note
|
|
push_event(
|
|
&turn_events,
|
|
TurnEvent::SystemNote {
|
|
kind: "review".into(),
|
|
message: "🔍 Auto-review: examining and fixing issues...".into(),
|
|
},
|
|
);
|
|
|
|
// 4. Build minimal ToolCtx
|
|
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();
|
|
|
|
// 5. Determine base URL
|
|
let base_url = api_base.unwrap_or_else(|| {
|
|
std::env::var("OPENAI_API_BASE")
|
|
.unwrap_or_else(|_| "https://opencode.ai/zen/v1".to_string())
|
|
});
|
|
|
|
// 6. Build SubagentContext
|
|
let subagent_ctx = SubagentContext::new(
|
|
directive,
|
|
tool_ctx.clone(),
|
|
"write".to_string(),
|
|
base_url,
|
|
api_key,
|
|
model,
|
|
);
|
|
|
|
// 7. Run the subagent engine directly on this thread
|
|
// (creates its own tokio runtime, calls run_agent with Write tools)
|
|
let rt = match tokio::runtime::Runtime::new() {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
warn!(error = %e, "auto-review: failed to create tokio runtime");
|
|
push_event(
|
|
&turn_events,
|
|
TurnEvent::WorkflowAgentUpdate {
|
|
agent_id,
|
|
agent_name,
|
|
status: AgentStatus::Failed(format!("runtime error: {e}")),
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let result = rt.block_on(run_agent(
|
|
subagent_ctx,
|
|
"Auto-review and fix issues in the changed files",
|
|
AccessTier::Write,
|
|
tool_ctx,
|
|
));
|
|
|
|
// 8. Report results
|
|
match result {
|
|
Ok(report) => {
|
|
let trimmed = report.trim();
|
|
if trimmed.is_empty() || trimmed.contains("no issues") {
|
|
push_event(
|
|
&turn_events,
|
|
TurnEvent::SystemNote {
|
|
kind: "review".into(),
|
|
message: "✅ Auto-review: no issues found.".into(),
|
|
},
|
|
);
|
|
info!("auto-review: no issues found");
|
|
} else {
|
|
push_event(
|
|
&turn_events,
|
|
TurnEvent::SystemNote {
|
|
kind: "review_finding".into(),
|
|
message: format!("📋 Auto-review complete:\n{}", trimmed),
|
|
},
|
|
);
|
|
info!("auto-review: completed with findings");
|
|
}
|
|
push_event(
|
|
&turn_events,
|
|
TurnEvent::WorkflowAgentUpdate {
|
|
agent_id,
|
|
agent_name,
|
|
status: AgentStatus::Completed,
|
|
},
|
|
);
|
|
}
|
|
Err(e) => {
|
|
warn!(error = %e, "auto-review subagent failed");
|
|
push_event(
|
|
&turn_events,
|
|
TurnEvent::SystemNote {
|
|
kind: "review".into(),
|
|
message: format!("⚠️ Auto-review encountered an error: {e}"),
|
|
},
|
|
);
|
|
push_event(
|
|
&turn_events,
|
|
TurnEvent::WorkflowAgentUpdate {
|
|
agent_id,
|
|
agent_name,
|
|
status: AgentStatus::Failed(e.to_string()),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|