From dae99d307a695bf1bb6dce11e7461d0b8563ff21 Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Thu, 12 Mar 2026 19:25:35 +0000 Subject: [PATCH] feat: support require_plan_approval in template config Templates can now set "require_plan_approval": true in template.json to require user approval after plan generation before execution begins. On rejection, the LLM re-enters the planning loop with user feedback. --- src/agent.rs | 106 +++++++++++++++++++++++++++++++++++++++++++----- src/template.rs | 10 +++++ 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 7845729..73c6dd3 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -350,6 +350,7 @@ async fn agent_loop( }; let ext_tools = loaded_template.as_ref().map(|t| &t.external_tools); + let plan_approval = loaded_template.as_ref().map_or(false, |t| t.require_plan_approval); tracing::info!("Starting agent loop for workflow {}", workflow_id); // Run tool-calling agent loop @@ -357,6 +358,7 @@ async fn agent_loop( &llm, &exec, &pool, &broadcast_tx, &project_id, &workflow_id, &requirement, &workdir, &mgr, &instructions, None, ext_tools, &mut rx, + plan_approval, ).await; let final_status = if result.is_ok() { "done" } else { "failed" }; @@ -489,14 +491,13 @@ async fn agent_loop( let instructions = read_instructions(&workdir).await; - // Reload external tools from template if available - let ext_tools = if !wf.template_id.is_empty() { + // Reload template config if available + let loaded_template = if !wf.template_id.is_empty() { let tid = &wf.template_id; if template::is_repo_template(tid) { match template::extract_repo_template(tid, mgr.template_repo.as_ref()).await { Ok(template_dir) => { - LoadedTemplate::load_from_dir(tid, &template_dir).await - .ok().map(|t| t.external_tools) + LoadedTemplate::load_from_dir(tid, &template_dir).await.ok() } Err(e) => { tracing::warn!("Failed to reload template {}: {}", tid, e); @@ -504,16 +505,19 @@ async fn agent_loop( } } } else { - LoadedTemplate::load(tid).await.ok().map(|t| t.external_tools) + LoadedTemplate::load(tid).await.ok() } } else { None }; + let ext_tools = loaded_template.as_ref().map(|t| &t.external_tools); + let plan_approval = loaded_template.as_ref().map_or(false, |t| t.require_plan_approval); let result = run_agent_loop( &llm, &exec, &pool, &broadcast_tx, &project_id, &workflow_id, &wf.requirement, &workdir, &mgr, - &instructions, Some(state), ext_tools.as_ref(), &mut rx, + &instructions, Some(state), ext_tools, &mut rx, + plan_approval, ).await; let final_status = if result.is_ok() { "done" } else { "failed" }; @@ -1638,6 +1642,7 @@ async fn run_agent_loop( initial_state: Option, external_tools: Option<&ExternalToolManager>, event_rx: &mut mpsc::Receiver, + require_plan_approval: bool, ) -> anyhow::Result<()> { let planning_tools = build_planning_tools(); let coordinator_tools = build_coordinator_tools(); @@ -1709,21 +1714,100 @@ async fn run_agent_loop( }); } - if let Some(first) = state.steps.first_mut() { - first.status = StepStatus::Running; - } - let _ = broadcast_tx.send(WsMessage::PlanUpdate { workflow_id: workflow_id.to_string(), steps: plan_infos_from_state(&state), }); + save_state_snapshot(pool, workflow_id, 0, &state).await; + tracing::info!("[workflow {}] Plan set ({} steps)", workflow_id, state.steps.len()); + + // If require_plan_approval, wait for user to confirm the plan + if require_plan_approval { + tracing::info!("[workflow {}] Waiting for plan approval", workflow_id); + let _ = broadcast_tx.send(WsMessage::ActivityUpdate { + workflow_id: workflow_id.to_string(), + activity: "计划已生成 — 等待用户确认...".to_string(), + }); + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { + workflow_id: workflow_id.to_string(), + status: "waiting_approval".into(), + }); + let _ = sqlx::query("UPDATE workflows SET status = 'waiting_approval' WHERE id = ?") + .bind(workflow_id) + .execute(pool) + .await; + log_execution(pool, broadcast_tx, workflow_id, 0, "plan_approval", "等待确认计划", "等待用户确认执行计划", "waiting").await; + + // Block until Comment event + let approval_content = loop { + match event_rx.recv().await { + Some(AgentEvent::Comment { content, .. }) => break content, + Some(_) => continue, + None => { + anyhow::bail!("Event channel closed while waiting for plan approval"); + } + } + }; + + tracing::info!("[workflow {}] Plan approval response: {}", workflow_id, approval_content); + + if approval_content.starts_with("rejected:") { + let reason = approval_content.strip_prefix("rejected:").unwrap_or("").trim(); + tracing::info!("[workflow {}] Plan rejected: {}", workflow_id, reason); + log_execution(pool, broadcast_tx, workflow_id, 0, "plan_approval", "rejected", reason, "failed").await; + + // Feed rejection back into planning conversation so LLM can re-plan + state.current_step_chat_history.push(ChatMessage::tool_result( + &tc.id, + &format!("用户拒绝了此计划: {}。请根据反馈修改计划后重新调用 update_plan。", reason), + )); + state.steps.clear(); + + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { + workflow_id: workflow_id.to_string(), + status: "executing".into(), + }); + let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?") + .bind(workflow_id) + .execute(pool) + .await; + // Stay in Planning phase, continue the loop + continue; + } + + // Approved + let feedback = if approval_content.starts_with("approved:") { + approval_content.strip_prefix("approved:").unwrap_or("").trim().to_string() + } else { + String::new() + }; + + log_execution(pool, broadcast_tx, workflow_id, 0, "plan_approval", "approved", &feedback, "done").await; + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { + workflow_id: workflow_id.to_string(), + status: "executing".into(), + }); + let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?") + .bind(workflow_id) + .execute(pool) + .await; + } + + // Enter execution phase + if let Some(first) = state.steps.first_mut() { + first.status = StepStatus::Running; + } + let _ = broadcast_tx.send(WsMessage::PlanUpdate { + workflow_id: workflow_id.to_string(), + steps: plan_infos_from_state(&state), + }); state.current_step_chat_history.clear(); state.phase = AgentPhase::Executing { step: 1 }; phase_transition = true; save_state_snapshot(pool, workflow_id, 0, &state).await; - tracing::info!("[workflow {}] Plan set ({} steps), entering Executing", workflow_id, state.steps.len()); + tracing::info!("[workflow {}] Entering Executing", workflow_id); } // Planning phase IO tools _ => { diff --git a/src/template.rs b/src/template.rs index 312477c..091fdcf 100644 --- a/src/template.rs +++ b/src/template.rs @@ -12,6 +12,10 @@ pub struct TemplateInfo { pub name: String, pub description: String, pub match_hint: String, + /// If true, the agent will wait for user approval after update_plan + /// before entering the execution phase. + #[serde(default)] + pub require_plan_approval: bool, } #[allow(dead_code)] @@ -21,6 +25,7 @@ pub struct LoadedTemplate { pub instructions: String, pub external_tools: ExternalToolManager, pub kb_files: Vec<(String, String)>, + pub require_plan_approval: bool, } #[derive(Debug, Clone, serde::Serialize)] @@ -590,12 +595,14 @@ impl LoadedTemplate { name: template_id.to_string(), description: String::new(), match_hint: String::new(), + require_plan_approval: false, }) } else { TemplateInfo { name: template_id.to_string(), description: String::new(), match_hint: String::new(), + require_plan_approval: false, } }; @@ -610,12 +617,15 @@ impl LoadedTemplate { let kb_files = scan_kb_files(&kb_dir).await; tracing::info!("Template '{}': {} KB files", template_id, kb_files.len()); + let require_plan_approval = info.require_plan_approval; + Ok(Self { id: template_id.to_string(), info, instructions, external_tools, kb_files, + require_plan_approval, }) }