refactor: rename wait_for_approval to ask_user

More general-purpose user intervention tool — not just approve/reject,
but any question or input request. Renames across Rust backend, Vue
frontend, prompts, and status strings.

Tool: wait_for_approval → ask_user (param: reason → question)
Status: WaitingApproval → WaitingUser, waiting_approval → waiting_user
Enum: NeedsApproval → NeedsInput
This commit is contained in:
Fam Zheng
2026-03-16 08:50:24 +00:00
parent dae99d307a
commit f2fa721ef0
10 changed files with 194 additions and 47 deletions

View File

@@ -56,7 +56,7 @@ pub fn plan_infos_from_state(state: &AgentState) -> Vec<PlanStepInfo> {
let status = match s.status {
StepStatus::Pending => "pending",
StepStatus::Running => "running",
StepStatus::WaitingApproval => "waiting_approval",
StepStatus::WaitingUser => "waiting_user",
StepStatus::Done => "done",
StepStatus::Failed => "failed",
};
@@ -428,18 +428,18 @@ async fn agent_loop(
.and_then(|json| serde_json::from_str::<AgentState>(&json).ok())
.unwrap_or_else(AgentState::new);
// Resume directly if: workflow is failed/done/waiting_approval,
// OR if state snapshot has a WaitingApproval step (e.g. after pod restart)
let has_waiting_step = state.steps.iter().any(|s| matches!(s.status, StepStatus::WaitingApproval));
// Resume directly if: workflow is failed/done/waiting_user,
// OR if state snapshot has a WaitingUser step (e.g. after pod restart)
let has_waiting_step = state.steps.iter().any(|s| matches!(s.status, StepStatus::WaitingUser));
let is_resuming = wf.status == "failed" || wf.status == "done"
|| wf.status == "waiting_approval" || has_waiting_step;
|| wf.status == "waiting_user" || has_waiting_step;
if is_resuming {
// Reset Failed/WaitingApproval steps so they get re-executed
// Reset Failed/WaitingUser steps so they get re-executed
for step in &mut state.steps {
if matches!(step.status, StepStatus::Failed) {
step.status = StepStatus::Pending;
}
if matches!(step.status, StepStatus::WaitingApproval) {
if matches!(step.status, StepStatus::WaitingUser) {
// Mark as Running so it continues (not re-plans)
step.status = StepStatus::Running;
}
@@ -483,7 +483,7 @@ async fn agent_loop(
}
state.phase = AgentPhase::Executing { step: next };
// Only clear chat history when advancing to a new step;
// keep it when resuming the same step after wait_for_approval
// keep it when resuming the same step after ask_user
if !was_same_step {
state.current_step_chat_history.clear();
}
@@ -722,12 +722,12 @@ fn build_step_tools() -> Vec<Tool> {
},
"required": ["content"]
})),
make_tool("wait_for_approval", "暂停执行等待用户确认后继续。用于关键决策点", serde_json::json!({
make_tool("ask_user", "向用户提问,暂停执行等待用户回复。用于需要用户输入、确认或决策的场景", serde_json::json!({
"type": "object",
"properties": {
"reason": { "type": "string", "description": "说明为什么需要用户确认" }
"question": { "type": "string", "description": "要向用户提出的问题或需要确认的内容" }
},
"required": ["reason"]
"required": ["question"]
})),
make_tool("step_done", "完成当前步骤。必须提供摘要和产出物列表(无产出物时传空数组)。", serde_json::json!({
"type": "object",
@@ -827,7 +827,7 @@ fn build_feedback_prompt(project_id: &str, state: &AgentState, feedback: &str) -
let status = match s.status {
StepStatus::Done => " [done]",
StepStatus::Running => " [running]",
StepStatus::WaitingApproval => " [waiting]",
StepStatus::WaitingUser => " [waiting]",
StepStatus::Failed => " [FAILED]",
StepStatus::Pending => "",
};
@@ -1326,8 +1326,8 @@ async fn run_step_loop(
}
}
"wait_for_approval" => {
let reason = args["reason"].as_str().unwrap_or("等待确认");
"ask_user" => {
let reason = args["question"].as_str().unwrap_or("等待确认");
let _ = broadcast_tx.send(WsMessage::ActivityUpdate {
workflow_id: workflow_id.to_string(),
activity: format!("步骤 {} — 等待用户确认: {}", step_order, reason),
@@ -1336,18 +1336,18 @@ async fn run_step_loop(
// Broadcast waiting status
let _ = broadcast_tx.send(WsMessage::PlanUpdate {
workflow_id: workflow_id.to_string(),
steps: plan_infos_from_state_with_override(step_order, "waiting_approval",
steps: plan_infos_from_state_with_override(step_order, "waiting_user",
pool, workflow_id).await,
});
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
workflow_id: workflow_id.to_string(),
status: "waiting_approval".into(),
status: "waiting_user".into(),
});
let _ = sqlx::query("UPDATE workflows SET status = 'waiting_approval' WHERE id = ?")
let _ = sqlx::query("UPDATE workflows SET status = 'waiting_user' WHERE id = ?")
.bind(workflow_id)
.execute(pool)
.await;
log_execution(pool, broadcast_tx, workflow_id, step_order, "wait_for_approval", reason, reason, "waiting").await;
log_execution(pool, broadcast_tx, workflow_id, step_order, "ask_user", reason, reason, "waiting").await;
tracing::info!("[workflow {}] Step {} waiting for approval: {}", workflow_id, step_order, reason);
@@ -1370,7 +1370,7 @@ async fn run_step_loop(
if approval_content.starts_with("rejected:") {
let reason = approval_content.strip_prefix("rejected:").unwrap_or("").trim();
log_execution(pool, broadcast_tx, workflow_id, step_order, "wait_for_approval", "rejected", reason, "failed").await;
log_execution(pool, broadcast_tx, workflow_id, step_order, "ask_user", "rejected", reason, "failed").await;
step_chat_history.push(ChatMessage::tool_result(&tc.id, &format!("用户拒绝: {}", reason)));
step_done_result = Some(StepResult {
status: StepResultStatus::Failed { error: format!("用户终止: {}", reason) },
@@ -1582,7 +1582,7 @@ async fn run_step_loop(
}
/// Helper to get plan step infos with a status override for a specific step.
/// Used during wait_for_approval in the step sub-loop where we don't have
/// Used during ask_user in the step sub-loop where we don't have
/// mutable access to the AgentState.
async fn plan_infos_from_state_with_override(
step_order: i32,
@@ -1609,7 +1609,7 @@ async fn plan_infos_from_state_with_override(
match s.status {
StepStatus::Pending => "pending",
StepStatus::Running => "running",
StepStatus::WaitingApproval => "waiting_approval",
StepStatus::WaitingUser => "waiting_user",
StepStatus::Done => "done",
StepStatus::Failed => "failed",
}.to_string()
@@ -1731,9 +1731,9 @@ async fn run_agent_loop(
});
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
workflow_id: workflow_id.to_string(),
status: "waiting_approval".into(),
status: "waiting_user".into(),
});
let _ = sqlx::query("UPDATE workflows SET status = 'waiting_approval' WHERE id = ?")
let _ = sqlx::query("UPDATE workflows SET status = 'waiting_user' WHERE id = ?")
.bind(workflow_id)
.execute(pool)
.await;
@@ -1896,11 +1896,11 @@ async fn run_agent_loop(
save_state_snapshot(pool, workflow_id, step_order, &state).await;
return Err(anyhow::anyhow!("Step {} failed: {}", step_order, error));
}
StepResultStatus::NeedsApproval { message: _ } => {
// This shouldn't normally happen since wait_for_approval is handled inside
StepResultStatus::NeedsInput { message: _ } => {
// This shouldn't normally happen since ask_user is handled inside
// run_step_loop, but handle gracefully
if let Some(s) = state.steps.iter_mut().find(|s| s.order == step_order) {
s.status = StepStatus::WaitingApproval;
s.status = StepStatus::WaitingUser;
}
save_state_snapshot(pool, workflow_id, step_order, &state).await;
continue;
@@ -1934,7 +1934,7 @@ async fn run_agent_loop(
let marker = match s.status {
StepStatus::Done => " [done]",
StepStatus::Running => " [running]",
StepStatus::WaitingApproval => " [waiting]",
StepStatus::WaitingUser => " [waiting]",
StepStatus::Failed => " [FAILED]",
StepStatus::Pending => "",
};
@@ -2238,7 +2238,7 @@ mod tests {
let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect();
for expected in &["execute", "read_file", "write_file", "list_files",
"start_service", "stop_service", "update_scratchpad",
"wait_for_approval", "kb_search", "kb_read"] {
"ask_user", "kb_search", "kb_read"] {
assert!(names.contains(expected), "{} must be in step tools", expected);
}
}

View File

@@ -115,6 +115,15 @@ async fn serve_project_file(
match tokio::fs::read(&full_path).await {
Ok(bytes) => {
// Render markdown files as HTML
if full_path.extension().is_some_and(|e| e == "md") {
let md = String::from_utf8_lossy(&bytes);
let html = render_markdown_page(&md, &file_path);
return (
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())],
html,
).into_response();
}
let mime = mime_guess::from_path(&full_path)
.first_or_octet_stream()
.to_string();
@@ -127,3 +136,105 @@ async fn serve_project_file(
}
}
fn render_markdown_page(markdown: &str, title: &str) -> String {
use pulldown_cmark::{Parser, Options, html};
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(markdown, opts);
let mut body = String::new();
html::push_html(&mut body, parser);
format!(r#"<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<style>
:root {{
--text-primary: #1a1a2e;
--text-secondary: #6b7280;
--bg-primary: #ffffff;
--bg-secondary: #f7f8fa;
--border: #e2e5ea;
--accent: #2563eb;
--error: #dc2626;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
margin: 0;
padding: 0;
}}
.page {{
max-width: 860px;
margin: 0 auto;
padding: 24px 32px;
min-height: 100vh;
}}
.toolbar {{
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}}
.toolbar a {{
color: var(--accent);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}}
.toolbar a:hover {{ text-decoration: underline; }}
.toolbar .title {{
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.5px;
}}
.body {{ line-height: 1.7; font-size: 15px; }}
.body h1 {{ font-size: 24px; font-weight: 700; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 2px solid var(--border); }}
.body h2 {{ font-size: 20px; font-weight: 600; margin: 24px 0 12px; }}
.body h3 {{ font-size: 16px; font-weight: 600; margin: 20px 0 8px; }}
.body p {{ margin: 0 0 12px; }}
.body ul, .body ol {{ margin: 0 0 12px; padding-left: 24px; }}
.body li {{ margin-bottom: 4px; }}
.body pre {{
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
overflow-x: auto;
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
}}
.body code {{ font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; }}
.body :not(pre) > code {{ background: var(--bg-secondary); padding: 2px 6px; border-radius: 4px; }}
.body table {{ width: 100%; border-collapse: collapse; margin: 0 0 12px; font-size: 14px; }}
.body th, .body td {{ border: 1px solid var(--border); padding: 8px 12px; text-align: left; }}
.body th {{ background: var(--bg-secondary); font-weight: 600; }}
.body blockquote {{ border-left: 3px solid var(--accent); padding-left: 16px; margin: 0 0 12px; color: var(--text-secondary); }}
.body a {{ color: var(--accent); text-decoration: none; }}
.body a:hover {{ text-decoration: underline; }}
.body img {{ max-width: 100%; border-radius: 6px; }}
.body hr {{ border: none; border-top: 1px solid var(--border); margin: 20px 0; }}
.body input[type="checkbox"] {{ margin-right: 6px; }}
</style>
</head>
<body>
<div class="page">
<div class="toolbar">
<a href="javascript:history.back()">&larr; 返回</a>
<span class="title">{title}</span>
</div>
<div class="body">{body}</div>
</div>
</body>
</html>"#, title = title, body = body)
}

View File

@@ -7,7 +7,7 @@
- start_service / stop_service管理后台服务
- kb_search / kb_read搜索和读取知识库
- update_scratchpad记录本步骤内的中间状态步骤结束后丢弃精华写进 summary
- wait_for_approval暂停执行等待用户确认
- ask_user向用户提问暂停执行等待用户回复
- step_done**完成当前步骤时必须调用**,提供本步骤的工作摘要
## 工作流程
@@ -21,7 +21,7 @@
- **专注当前步骤**,不做超出范围的事
- 完成后**必须**调用 step_done(summary)summary 应简洁概括本步骤做了什么、结果如何
- 完成步骤时,用 `step_done``artifacts` 参数声明本步骤产出的文件。每个产出物需要 name、path、type (file/json/markdown)
- 需要用户确认时使用 wait_for_approval(reason)
- 需要用户确认或输入时使用 ask_user(question)
- update_scratchpad 用于记录本步骤内的中间状态,是工作记忆而非日志,只保留当前有用的信息
## 环境信息

View File

@@ -15,7 +15,7 @@ pub struct StepResult {
pub enum StepResultStatus {
Done,
Failed { error: String },
NeedsApproval { message: String },
NeedsInput { message: String },
}
/// Check scratchpad size. Limit: ~8K tokens ≈ 24K bytes.
@@ -50,7 +50,7 @@ pub enum AgentPhase {
pub enum StepStatus {
Pending,
Running,
WaitingApproval,
WaitingUser,
Done,
Failed,
}
@@ -183,7 +183,7 @@ impl AgentState {
/// 全部 Done 时返回 None。
pub fn first_actionable_step(&self) -> Option<i32> {
self.steps.iter()
.find(|s| matches!(s.status, StepStatus::Pending | StepStatus::Running | StepStatus::WaitingApproval))
.find(|s| matches!(s.status, StepStatus::Pending | StepStatus::Running | StepStatus::WaitingUser))
.map(|s| s.order)
}
@@ -204,7 +204,7 @@ impl AgentState {
let marker = match s.status {
StepStatus::Done => " done",
StepStatus::Running => " >> current",
StepStatus::WaitingApproval => " ⏳ waiting",
StepStatus::WaitingUser => " ⏳ waiting",
StepStatus::Failed => " FAILED",
StepStatus::Pending => "",
};
@@ -421,11 +421,11 @@ mod tests {
}
#[test]
fn first_actionable_finds_waiting_approval() {
fn first_actionable_finds_waiting_user() {
let state = AgentState {
phase: AgentPhase::Executing { step: 1 },
steps: vec![
make_step(1, "A", "a", StepStatus::WaitingApproval),
make_step(1, "A", "a", StepStatus::WaitingUser),
make_step(2, "B", "b", StepStatus::Pending),
],
current_step_chat_history: Vec::new(),