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.
This commit is contained in:
Fam Zheng
2026-03-12 19:25:35 +00:00
parent 30e25f589b
commit dae99d307a
2 changed files with 105 additions and 11 deletions

View File

@@ -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<AgentState>,
external_tools: Option<&ExternalToolManager>,
event_rx: &mut mpsc::Receiver<AgentEvent>,
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
_ => {

View File

@@ -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,
})
}