refactor: streamline agent turn handling and background review process
This commit is contained in:
@@ -149,10 +149,7 @@ impl SessionLock {
|
|||||||
|
|
||||||
// Phase 3: re-check /proc/<pid>/exe to detect PID reuse between
|
// Phase 3: re-check /proc/<pid>/exe to detect PID reuse between
|
||||||
// Phase 1 and Phase 2.
|
// Phase 1 and Phase 2.
|
||||||
match std::fs::read_link(&proc_exe) {
|
matches!(std::fs::read_link(&proc_exe), Ok(recheck) if recheck == self_exe)
|
||||||
Ok(recheck) if recheck == self_exe => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use tracing::{debug, info, warn};
|
|||||||
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
|
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
|
||||||
use zesdex_domain::core::ChatMessage;
|
use zesdex_domain::core::ChatMessage;
|
||||||
use crate::llm::provider::LlmClient;
|
use crate::llm::provider::LlmClient;
|
||||||
use crate::subagent::auto::engine::trigger_auto_review;
|
use crate::subagent::auto::engine::spawn_background_review;
|
||||||
use crate::tools::{all_tools, tool_defs, ToolCtx};
|
use crate::tools::{all_tools, tool_defs, ToolCtx};
|
||||||
use crate::TurnEvent;
|
use crate::TurnEvent;
|
||||||
|
|
||||||
@@ -26,8 +26,6 @@ pub struct AgentTurnParams {
|
|||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
pub edit_count: u32,
|
|
||||||
pub consecutive_empty_reviews: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns an agent turn on a background OS thread.
|
/// Spawns an agent turn on a background OS thread.
|
||||||
@@ -201,23 +199,6 @@ fn run_turn(params: &mut AgentTurnParams) {
|
|||||||
params
|
params
|
||||||
.messages
|
.messages
|
||||||
.push(ChatMessage::tool(tc.id.clone(), output.clone()));
|
.push(ChatMessage::tool(tc.id.clone(), output.clone()));
|
||||||
|
|
||||||
// Trigger auto-review after write/edit tool execution
|
|
||||||
if name == "write" || name == "edit" {
|
|
||||||
params.edit_count = params.edit_count.saturating_add(1);
|
|
||||||
|
|
||||||
// If we have a workspace root, trigger review
|
|
||||||
if let Some(root) = params.workspace_roots.first() {
|
|
||||||
let _ = trigger_auto_review(
|
|
||||||
root,
|
|
||||||
params.edit_count,
|
|
||||||
&mut params.consecutive_empty_reviews,
|
|
||||||
3, // max_skip: skip after 3 consecutive empty reviews
|
|
||||||
¶ms.turn_events,
|
|
||||||
Some(&client),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -231,6 +212,26 @@ fn run_turn(params: &mut AgentTurnParams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If edits were made, spawn a background auto-review after the turn ends.
|
||||||
|
// This runs asynchronously — findings arrive as TurnEvent::SystemNote events.
|
||||||
|
let had_edits = params
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.any(|m| {
|
||||||
|
m.role == zesdex_domain::core::Role::Tool
|
||||||
|
&& m.content.as_deref().unwrap_or("").contains("Written")
|
||||||
|
});
|
||||||
|
if had_edits {
|
||||||
|
info!("edits detected, spawning background auto-review");
|
||||||
|
spawn_background_review(
|
||||||
|
params.workspace_roots.clone(),
|
||||||
|
params.turn_events.clone(),
|
||||||
|
params.api_key.clone(),
|
||||||
|
params.model.clone(),
|
||||||
|
params.api_base.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Propagate accumulated messages back to caller so the next turn starts
|
// Propagate accumulated messages back to caller so the next turn starts
|
||||||
// with full history (assistant replies + tool results).
|
// with full history (assistant replies + tool results).
|
||||||
push_event(
|
push_event(
|
||||||
|
|||||||
@@ -102,9 +102,6 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
|
|||||||
|
|
||||||
// Phase 3: re-check /proc/<pid>/exe to detect PID reuse between
|
// Phase 3: re-check /proc/<pid>/exe to detect PID reuse between
|
||||||
// Phase 1 and Phase 2.
|
// Phase 1 and Phase 2.
|
||||||
match std::fs::read_link(&proc_exe) {
|
matches!(std::fs::read_link(&proc_exe), Ok(recheck) if recheck == self_exe)
|
||||||
Ok(recheck) if recheck == self_exe => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,280 +1,305 @@
|
|||||||
//! Auto-review engine — automatically checks git diff after file edits
|
//! Auto-review engine — after edits, spawns a subagent that reviews AND
|
||||||
//! using an LLM subagent.
|
//! automatically fixes issues using tool access (edit/write/grep).
|
||||||
//!
|
//!
|
||||||
//! Flow: after each write/edit tool execution in the agent turn, the runner
|
//! Flow: after an agent turn with edits completes:
|
||||||
//! calls `trigger_auto_review` which:
|
//! 1. Emit `WorkflowAgentUpdate(Running)` → visible in workflow sidebar
|
||||||
//! 1. Runs `git diff --cached` and `git diff` to get working-tree changes
|
//! 2. Run `git diff` to get the changed files
|
||||||
//! 2. Sends the diff to a lightweight LLM call for quick review
|
//! 3. Spawn the subagent engine with Write-tier tools + directive to
|
||||||
//! 3. Emits findings as `TurnEvent::SystemNote` on the event queue
|
//! 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::collections::VecDeque;
|
||||||
use std::path::Path;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use tracing::{debug, info, instrument, warn};
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
use crate::llm::provider::LlmClient;
|
use crate::subagent::context::SubagentContext;
|
||||||
use crate::subagent::gating::should_review;
|
use crate::subagent::division::AccessTier;
|
||||||
use crate::TurnEvent;
|
use crate::subagent::engine::run_agent;
|
||||||
|
use crate::tools::ToolCtx;
|
||||||
|
use crate::{AgentStatus, TurnEvent};
|
||||||
|
|
||||||
/// Trigger an auto-review of recent git changes.
|
const REVIEW_AGENT_ID: &str = "auto-review";
|
||||||
|
|
||||||
|
/// Spawn a review subagent that reviews changes and auto-fixes issues.
|
||||||
///
|
///
|
||||||
/// Flow:
|
/// The subagent runs inline on the current background thread (no extra
|
||||||
/// 1. Check gating conditions (edit count, consecutive empty reviews)
|
/// thread spawn) with its own tokio runtime and Write-tier tool access
|
||||||
/// 2. Run `git diff --cached` to get staged changes
|
/// (edit, write, grep, read, glob). It receives the git diff as context
|
||||||
/// 3. Run `git diff` to get unstaged changes
|
/// and is directed to:
|
||||||
/// 4. If there are changes, call LLM for a quick review
|
/// 1. Read changed files
|
||||||
/// 5. Emit findings as TurnEvent::SystemNote
|
/// 2. Check for typos, missing imports, syntax errors, logic bugs
|
||||||
|
/// 3. Fix any issues found using edit/write tools
|
||||||
|
/// 4. Report what was fixed
|
||||||
///
|
///
|
||||||
/// Returns `(had_findings, total_findings)` tuple.
|
/// All findings stream as TurnEvent events consumed by the TUI event loop.
|
||||||
#[instrument(skip(turn_events, llm_client))]
|
#[instrument(skip(turn_events))]
|
||||||
pub fn trigger_auto_review(
|
pub fn spawn_background_review(
|
||||||
workspace_root: &Path,
|
workspace_roots: Vec<PathBuf>,
|
||||||
edit_count: u32,
|
turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||||
consecutive_empty_reviews: &mut u32,
|
api_key: String,
|
||||||
max_skip: u32,
|
model: String,
|
||||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
api_base: Option<String>,
|
||||||
llm_client: Option<&LlmClient>,
|
) {
|
||||||
) -> Result<(bool, usize)> {
|
let agent_id = REVIEW_AGENT_ID.to_string();
|
||||||
// Gating check
|
let agent_name = "Auto-Review".to_string();
|
||||||
if !should_review(edit_count, *consecutive_empty_reviews, max_skip) {
|
|
||||||
debug!("auto-review skipped by gating");
|
|
||||||
return Ok((false, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("triggering auto-review");
|
std::thread::spawn(move || {
|
||||||
|
let root = match workspace_roots.first() {
|
||||||
|
Some(r) => r.clone(),
|
||||||
|
None => {
|
||||||
|
debug!("auto-review: no workspace root, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Run git diff to get changes
|
info!("auto-review: starting");
|
||||||
let diff = match get_git_diff(workspace_root) {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
debug!(error = %e, "auto-review: git diff failed (not a git repo?)");
|
|
||||||
return Ok((false, 0));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if diff.is_empty() {
|
// Mark Running in workflow panel
|
||||||
debug!("auto-review: no changes to review");
|
push_event(
|
||||||
*consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1);
|
&turn_events,
|
||||||
return Ok((false, 0));
|
TurnEvent::WorkflowAgentUpdate {
|
||||||
}
|
agent_id: agent_id.clone(),
|
||||||
|
agent_name: agent_name.clone(),
|
||||||
|
status: AgentStatus::Running,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// If we have an LLM client, do a real review
|
// 1. Get the git diff to know what changed
|
||||||
let review_result = if let Some(client) = llm_client {
|
let diff = match get_git_diff(&root) {
|
||||||
perform_llm_review(client, &diff)?
|
Ok(d) if !d.is_empty() => d,
|
||||||
} else {
|
Ok(_) => {
|
||||||
// Fallback: simple heuristic review without LLM
|
debug!("auto-review: no changes detected");
|
||||||
perform_heuristic_review(&diff)
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let had_findings = !review_result.is_empty();
|
// Truncate very large diffs for the prompt
|
||||||
let finding_count = review_result.len();
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
if had_findings {
|
// 2. Build directive: review AND fix issues using tools
|
||||||
*consecutive_empty_reviews = 0;
|
let directive = format!(
|
||||||
info!(finding_count, "auto-review produced findings");
|
"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```"
|
||||||
|
);
|
||||||
|
|
||||||
// Emit findings as SystemNote events
|
// 3. Emit progress note
|
||||||
for finding in &review_result {
|
push_event(
|
||||||
let note = TurnEvent::SystemNote {
|
&turn_events,
|
||||||
kind: "info".to_string(),
|
TurnEvent::SystemNote {
|
||||||
message: format!("🔍 Auto-Review: {finding}"),
|
kind: "review".into(),
|
||||||
};
|
message: "🔍 Auto-review: examining and fixing issues...".into(),
|
||||||
if let Ok(mut q) = turn_events.lock() {
|
},
|
||||||
q.push_back(note);
|
);
|
||||||
|
|
||||||
|
// 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()),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
*consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1);
|
|
||||||
info!("auto-review: no issues found");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((had_findings, finding_count))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `git diff` to get workspace changes (both staged and unstaged).
|
/// Run `git diff` to get workspace changes (both staged and unstaged).
|
||||||
fn get_git_diff(workspace_root: &Path) -> Result<String> {
|
fn get_git_diff(workspace_root: &PathBuf) -> Result<String, String> {
|
||||||
// Check if this is a git repo
|
|
||||||
let git_dir = workspace_root.join(".git");
|
let git_dir = workspace_root.join(".git");
|
||||||
if !git_dir.exists() {
|
if !git_dir.exists() {
|
||||||
return Ok(String::new());
|
return Err("not a git repository".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get unstaged diff
|
|
||||||
let unstaged = Command::new("git")
|
|
||||||
.arg("diff")
|
|
||||||
.current_dir(workspace_root)
|
|
||||||
.output()
|
|
||||||
.map_err(|e| anyhow::anyhow!("git diff failed: {e}"))?;
|
|
||||||
|
|
||||||
// Get staged diff
|
|
||||||
let staged = Command::new("git")
|
|
||||||
.arg("diff")
|
|
||||||
.arg("--cached")
|
|
||||||
.current_dir(workspace_root)
|
|
||||||
.output()
|
|
||||||
.map_err(|e| anyhow::anyhow!("git diff --cached failed: {e}"))?;
|
|
||||||
|
|
||||||
let mut combined = String::new();
|
let mut combined = String::new();
|
||||||
|
|
||||||
let staged_out = String::from_utf8_lossy(&staged.stdout).trim().to_string();
|
// Unstaged diff
|
||||||
if !staged_out.is_empty() {
|
if let Ok(out) = Command::new("git")
|
||||||
combined.push_str("=== Staged Changes ===\n");
|
.args(["diff"])
|
||||||
combined.push_str(&staged_out);
|
|
||||||
combined.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
let unstaged_out = String::from_utf8_lossy(&unstaged.stdout).trim().to_string();
|
|
||||||
if !unstaged_out.is_empty() {
|
|
||||||
combined.push_str("=== Unstaged Changes ===\n");
|
|
||||||
combined.push_str(&unstaged_out);
|
|
||||||
combined.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run `git diff --stat` for summary
|
|
||||||
let stat = Command::new("git")
|
|
||||||
.arg("diff")
|
|
||||||
.arg("--stat")
|
|
||||||
.current_dir(workspace_root)
|
.current_dir(workspace_root)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| anyhow::anyhow!("git diff --stat failed: {e}"))?;
|
{
|
||||||
let stat_out = String::from_utf8_lossy(&stat.stdout).trim().to_string();
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
if !stat_out.is_empty() {
|
if !stdout.is_empty() {
|
||||||
combined.push_str("=== Summary ===\n");
|
combined.push_str("=== Unstaged Changes ===\n");
|
||||||
combined.push_str(&stat_out);
|
combined.push_str(&stdout);
|
||||||
combined.push('\n');
|
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)
|
Ok(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform an LLM-based review of the git diff.
|
/// Push a TurnEvent onto the shared event queue.
|
||||||
///
|
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
|
||||||
/// Sends the diff to the model with a focused prompt asking it to find
|
if let Ok(mut q) = queue.lock() {
|
||||||
/// typos, missing imports, syntax errors, and other issues.
|
q.push_back(event);
|
||||||
fn perform_llm_review(client: &LlmClient, diff: &str) -> Result<Vec<String>> {
|
|
||||||
// Truncate very large diffs to avoid token limits
|
|
||||||
const MAX_DIFF_CHARS: usize = 8000;
|
|
||||||
let truncated_diff = if diff.len() > MAX_DIFF_CHARS {
|
|
||||||
format!("{}...\n[diff truncated at {} characters]",
|
|
||||||
&diff[..MAX_DIFF_CHARS], MAX_DIFF_CHARS)
|
|
||||||
} else {
|
|
||||||
diff.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let messages = vec![
|
|
||||||
zesdex_domain::core::ChatMessage::system(
|
|
||||||
"You are a focused code reviewer. Review the following git diff for:\n\
|
|
||||||
1. Typos and spelling errors\n\
|
|
||||||
2. Missing imports or undefined references\n\
|
|
||||||
3. Syntax errors or type mismatches\n\
|
|
||||||
4. Logic bugs or off-by-one errors\n\
|
|
||||||
5. Missing error handling\n\
|
|
||||||
6. Security issues\n\n\
|
|
||||||
Be concise. List each issue on a new line with severity [HIGH], [MEDIUM], or [LOW].\n\
|
|
||||||
If no issues are found, reply with exactly: NO_ISSUES_FOUND"
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
zesdex_domain::core::ChatMessage::user(format!(
|
|
||||||
"Review this git diff:\n\n```diff\n{truncated_diff}\n```"
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
|
|
||||||
match client.chat_with_tools_non_streaming(&messages, None, Some(1024), Some(0.3), None) {
|
|
||||||
Ok((response, _usage)) => {
|
|
||||||
let text = response.content.unwrap_or_default().trim().to_string();
|
|
||||||
|
|
||||||
if text.contains("NO_ISSUES_FOUND") {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse findings line by line
|
|
||||||
let findings: Vec<String> = text
|
|
||||||
.lines()
|
|
||||||
.map(|l| l.trim().to_string())
|
|
||||||
.filter(|l| {
|
|
||||||
!l.is_empty()
|
|
||||||
&& !l.starts_with("Here")
|
|
||||||
&& !l.starts_with("Let me")
|
|
||||||
&& !l.starts_with("I've")
|
|
||||||
&& !l.starts_with("The diff")
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(findings)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(error = %e, "auto-review LLM call failed");
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform a simple heuristic-based review without an LLM call.
|
|
||||||
///
|
|
||||||
/// This is a fallback when no LLM client is available. It checks for:
|
|
||||||
/// - Missing semicolons
|
|
||||||
/// - Unclosed brackets
|
|
||||||
/// - `todo!()` or `unimplemented!()` macros left in code
|
|
||||||
/// - Debug print statements
|
|
||||||
/// - Extremely long functions
|
|
||||||
fn perform_heuristic_review(diff: &str) -> Vec<String> {
|
|
||||||
let mut findings = Vec::new();
|
|
||||||
|
|
||||||
// Check for added lines (lines starting with +)
|
|
||||||
let added_lines: Vec<&str> = diff
|
|
||||||
.lines()
|
|
||||||
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let added_content: String = added_lines
|
|
||||||
.iter()
|
|
||||||
.map(|l| &l[1..]) // Strip leading +
|
|
||||||
.collect::<Vec<&str>>()
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
// Check for todo! and unimplemented!
|
|
||||||
if added_content.contains("todo!()") {
|
|
||||||
findings.push("[MEDIUM] `todo!()` found in new code — replace with implementation".to_string());
|
|
||||||
}
|
|
||||||
if added_content.contains("unimplemented!()") {
|
|
||||||
findings.push("[MEDIUM] `unimplemented!()` found in new code — replace with implementation".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for debug print statements
|
|
||||||
if added_content.contains("println!") || added_content.contains("dbg!") {
|
|
||||||
findings.push("[LOW] Debug print statements (println!/dbg!) found — consider removing before finalizing".to_string());
|
|
||||||
}
|
|
||||||
if added_content.contains("eprintln!") {
|
|
||||||
findings.push("[LOW] Debug eprintln! found — consider removing before finalizing".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for unreachable or panic statements
|
|
||||||
if added_content.contains("panic!(\"reached") || added_content.contains("panic!(\"not implemented") {
|
|
||||||
findings.push("[HIGH] Unreachable code / panic found — implement the missing logic".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for very long lines (>120 chars)
|
|
||||||
for (i, line) in added_lines.iter().enumerate() {
|
|
||||||
let content = &line[1..]; // Strip leading +
|
|
||||||
if content.len() > 120 && !content.trim_start().starts_with("//") {
|
|
||||||
let preview: String = content.chars().take(80).collect();
|
|
||||||
findings.push(format!(
|
|
||||||
"[LOW] Very long line ({} chars, line {} in diff) — consider breaking up:\n `{}…`",
|
|
||||||
content.len(),
|
|
||||||
i + 1,
|
|
||||||
preview
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count new functions to detect very long additions
|
|
||||||
let fn_count = added_content.matches("fn ").count();
|
|
||||||
if fn_count > 5 {
|
|
||||||
findings.push("[INFO] Large number of new functions ({fn_count}) — consider whether this should be split into separate modules".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
findings
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Auto-subagent path resolution.
|
//! Auto-subagent path resolution.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Resolve paths for auto-subagent scripts.
|
/// Resolve paths for auto-subagent scripts.
|
||||||
pub fn auto_subagent_dir(base_dir: &PathBuf) -> PathBuf {
|
pub fn auto_subagent_dir(base_dir: &Path) -> PathBuf {
|
||||||
base_dir.join("auto-agents")
|
base_dir.join("auto-agents")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ impl Tool for ParallelDelegate {
|
|||||||
let handle = spawn_subagent(
|
let handle = spawn_subagent(
|
||||||
subagent_ctx,
|
subagent_ctx,
|
||||||
directive.clone(),
|
directive.clone(),
|
||||||
access.clone(),
|
*access,
|
||||||
ctx.clone(),
|
ctx.clone(),
|
||||||
);
|
);
|
||||||
handles.push((i, handle));
|
handles.push((i, handle));
|
||||||
|
|||||||
@@ -468,18 +468,13 @@ fn extract_doc_comments(lines: &[&str]) -> HashMap<usize, String> {
|
|||||||
|
|
||||||
/// Find the next line that looks like a declaration (not doc, not attr).
|
/// Find the next line that looks like a declaration (not doc, not attr).
|
||||||
fn find_next_declaration_line(lines: &[&str], start: usize) -> Option<usize> {
|
fn find_next_declaration_line(lines: &[&str], start: usize) -> Option<usize> {
|
||||||
for i in start..lines.len() {
|
lines[start..].iter().position(|line| {
|
||||||
let trimmed = lines[i].trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.is_empty()
|
!trimmed.is_empty()
|
||||||
|| trimmed.starts_with("///")
|
&& !trimmed.starts_with("///")
|
||||||
|| trimmed.starts_with("//!")
|
&& !trimmed.starts_with("//!")
|
||||||
|| trimmed.starts_with('#')
|
&& !trimmed.starts_with('#')
|
||||||
{
|
}).map(|pos| start + pos)
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Some(i);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -163,28 +163,26 @@ pub fn build_workspace_tree(root: &Path, max_files: usize) -> String {
|
|||||||
|
|
||||||
// hidden(false) allows files like .github to be seen, but it still respects .gitignore
|
// hidden(false) allows files like .github to be seen, but it still respects .gitignore
|
||||||
// and ignores .git directories by default.
|
// and ignores .git directories by default.
|
||||||
for result in WalkBuilder::new(root).hidden(false).build() {
|
for entry in WalkBuilder::new(root).hidden(false).build().flatten() {
|
||||||
if let Ok(entry) = result {
|
if count >= max_files {
|
||||||
if count >= max_files {
|
tree.push_str("\n... (truncated)");
|
||||||
tree.push_str("\n... (truncated)");
|
break;
|
||||||
break;
|
}
|
||||||
}
|
|
||||||
|
let path = entry.path();
|
||||||
let path = entry.path();
|
if let Ok(rel) = path.strip_prefix(root) {
|
||||||
if let Ok(rel) = path.strip_prefix(root) {
|
let name = rel.to_string_lossy();
|
||||||
let name = rel.to_string_lossy();
|
if name.is_empty() {
|
||||||
if name.is_empty() {
|
tree.push_str(".\n");
|
||||||
tree.push_str(".\n");
|
} else {
|
||||||
} else {
|
let depth = entry.depth();
|
||||||
let depth = entry.depth();
|
let indent = " ".repeat(depth.saturating_sub(1));
|
||||||
let indent = " ".repeat(depth.saturating_sub(1));
|
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||||
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
let suffix = if is_dir { "/" } else { "" };
|
||||||
let suffix = if is_dir { "/" } else { "" };
|
|
||||||
|
let filename = entry.file_name().to_string_lossy();
|
||||||
let filename = entry.file_name().to_string_lossy();
|
tree.push_str(&format!("{indent}{filename}{suffix}\n"));
|
||||||
tree.push_str(&format!("{indent}{filename}{suffix}\n"));
|
count += 1;
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,8 +330,6 @@ fn handle_submit_input(state: &mut AppStateRest, text: String) {
|
|||||||
api_key,
|
api_key,
|
||||||
model: state.settings.model.clone(),
|
model: state.settings.model.clone(),
|
||||||
api_base: provider_cfg.map(|cfg| cfg.api_base.clone()),
|
api_base: provider_cfg.map(|cfg| cfg.api_base.clone()),
|
||||||
edit_count: 0,
|
|
||||||
consecutive_empty_reviews: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
||||||
|
|||||||
@@ -203,6 +203,76 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
|||||||
// Mark dirty so spinner disappears
|
// Mark dirty so spinner disappears
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate {
|
||||||
|
agent_id,
|
||||||
|
agent_name,
|
||||||
|
status,
|
||||||
|
} => {
|
||||||
|
// Find existing agent by ID, or create new one
|
||||||
|
let idx = state
|
||||||
|
.workflow_engine
|
||||||
|
.agents
|
||||||
|
.iter()
|
||||||
|
.position(|a| a.name == agent_id);
|
||||||
|
|
||||||
|
match status {
|
||||||
|
zesdex_infrastructure::AgentStatus::Pending => {
|
||||||
|
if idx.is_none() {
|
||||||
|
state.workflow_engine.agents.push(
|
||||||
|
crate::state::SimpleAgent::with_display(
|
||||||
|
agent_id,
|
||||||
|
agent_name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zesdex_infrastructure::AgentStatus::Running => {
|
||||||
|
if let Some(i) = idx {
|
||||||
|
state.workflow_engine.agents[i].state =
|
||||||
|
crate::state::AgentState::Running;
|
||||||
|
state.workflow_engine.agents[i].display_name =
|
||||||
|
agent_name;
|
||||||
|
state.workflow_engine.agents[i].started_at =
|
||||||
|
Some(chrono::Utc::now().timestamp_millis());
|
||||||
|
} else {
|
||||||
|
let mut agent =
|
||||||
|
crate::state::SimpleAgent::with_display(
|
||||||
|
agent_id,
|
||||||
|
agent_name,
|
||||||
|
);
|
||||||
|
agent.state = crate::state::AgentState::Running;
|
||||||
|
agent.started_at =
|
||||||
|
Some(chrono::Utc::now().timestamp_millis());
|
||||||
|
state.workflow_engine.agents.push(agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zesdex_infrastructure::AgentStatus::Completed => {
|
||||||
|
if let Some(i) = idx {
|
||||||
|
state.workflow_engine.agents[i].state =
|
||||||
|
crate::state::AgentState::Completed;
|
||||||
|
state.workflow_engine.agents[i].display_name =
|
||||||
|
agent_name;
|
||||||
|
state.workflow_engine.agents[i].completed_at =
|
||||||
|
Some(chrono::Utc::now().timestamp_millis());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zesdex_infrastructure::AgentStatus::Failed(msg) => {
|
||||||
|
if let Some(i) = idx {
|
||||||
|
state.workflow_engine.agents[i].state =
|
||||||
|
crate::state::AgentState::Failed;
|
||||||
|
state.workflow_engine.agents[i].display_name =
|
||||||
|
agent_name;
|
||||||
|
state.workflow_engine.agents[i].error = Some(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zesdex_infrastructure::AgentStatus::Cancelled => {
|
||||||
|
if let Some(i) = idx {
|
||||||
|
state.workflow_engine.agents.remove(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
debug!("unhandled turn event variant");
|
debug!("unhandled turn event variant");
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
|
|||||||
@@ -616,8 +616,10 @@ pub enum AgentState {
|
|||||||
/// A single agent entry in the workflow sidebar.
|
/// A single agent entry in the workflow sidebar.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SimpleAgent {
|
pub struct SimpleAgent {
|
||||||
/// Agent display name.
|
/// Agent unique ID (e.g. "auto-review", "Node-0-1").
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Human-readable display label (e.g. "Auto-Review", "Backend API Agent").
|
||||||
|
pub display_name: String,
|
||||||
/// Current lifecycle state.
|
/// Current lifecycle state.
|
||||||
pub state: AgentState,
|
pub state: AgentState,
|
||||||
/// Millisecond timestamp when the agent started.
|
/// Millisecond timestamp when the agent started.
|
||||||
@@ -631,9 +633,10 @@ pub struct SimpleAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SimpleAgent {
|
impl SimpleAgent {
|
||||||
/// Create a new agent with the given name.
|
/// Create a new agent with the given name (used as both ID and display name).
|
||||||
pub fn new(name: String) -> Self {
|
pub fn new(name: String) -> Self {
|
||||||
SimpleAgent {
|
SimpleAgent {
|
||||||
|
display_name: name.clone(),
|
||||||
name,
|
name,
|
||||||
state: AgentState::Idle,
|
state: AgentState::Idle,
|
||||||
started_at: None,
|
started_at: None,
|
||||||
@@ -642,6 +645,19 @@ impl SimpleAgent {
|
|||||||
progress: None,
|
progress: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new agent with separate ID and display label.
|
||||||
|
pub fn with_display(name: String, display_name: String) -> Self {
|
||||||
|
SimpleAgent {
|
||||||
|
name,
|
||||||
|
display_name,
|
||||||
|
state: AgentState::Idle,
|
||||||
|
started_at: None,
|
||||||
|
completed_at: None,
|
||||||
|
error: None,
|
||||||
|
progress: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simplified workflow engine state for TUI display.
|
/// Simplified workflow engine state for TUI display.
|
||||||
|
|||||||
@@ -61,17 +61,6 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
|||||||
|
|
||||||
info!("delegating agent turn to infrastructure engine (model: {})", model);
|
info!("delegating agent turn to infrastructure engine (model: {})", model);
|
||||||
|
|
||||||
let edit_count = state
|
|
||||||
.session_runtime
|
|
||||||
.as_ref()
|
|
||||||
.map(|rt| rt.edit_count)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let consecutive_empty_reviews = state
|
|
||||||
.session_runtime
|
|
||||||
.as_ref()
|
|
||||||
.map(|rt| rt.consecutive_empty_reviews)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let params = AgentTurnParams {
|
let params = AgentTurnParams {
|
||||||
messages,
|
messages,
|
||||||
session_dir,
|
session_dir,
|
||||||
@@ -82,8 +71,6 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
|||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
api_base,
|
api_base,
|
||||||
edit_count,
|
|
||||||
consecutive_empty_reviews,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
backend_spawn_agent_turn(params);
|
backend_spawn_agent_turn(params);
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ pub fn draw_workflow_panel(
|
|||||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(" {}", agent.name),
|
format!(" {}", agent.display_name),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Theme::TEXT)
|
.fg(Theme::TEXT)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
|
|||||||
@@ -92,8 +92,6 @@ async fn handle_socket(mut socket: WebSocket, state: Arc<WsState>) {
|
|||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
api_base: None,
|
api_base: None,
|
||||||
edit_count: 0,
|
|
||||||
consecutive_empty_reviews: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
||||||
|
|||||||
Reference in New Issue
Block a user