feat: add wait_for_approval tool for agent workflow pausing
Allow agent to pause execution at critical decision points and wait for user confirmation via comments before continuing.
This commit is contained in:
72
src/agent.rs
72
src/agent.rs
@@ -53,6 +53,7 @@ pub fn plan_infos_from_state(state: &AgentState) -> Vec<PlanStepInfo> {
|
|||||||
let status = match s.status {
|
let status = match s.status {
|
||||||
StepStatus::Pending => "pending",
|
StepStatus::Pending => "pending",
|
||||||
StepStatus::Running => "running",
|
StepStatus::Running => "running",
|
||||||
|
StepStatus::WaitingApproval => "waiting_approval",
|
||||||
StepStatus::Done => "done",
|
StepStatus::Done => "done",
|
||||||
StepStatus::Failed => "failed",
|
StepStatus::Failed => "failed",
|
||||||
};
|
};
|
||||||
@@ -319,7 +320,7 @@ async fn agent_loop(
|
|||||||
let result = run_agent_loop(
|
let result = run_agent_loop(
|
||||||
&llm, &exec, &pool, &broadcast_tx,
|
&llm, &exec, &pool, &broadcast_tx,
|
||||||
&project_id, &workflow_id, &requirement, &workdir, &mgr,
|
&project_id, &workflow_id, &requirement, &workdir, &mgr,
|
||||||
&instructions, None, ext_tools,
|
&instructions, None, ext_tools, &mut rx,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
let final_status = if result.is_ok() { "done" } else { "failed" };
|
let final_status = if result.is_ok() { "done" } else { "failed" };
|
||||||
@@ -427,7 +428,7 @@ async fn agent_loop(
|
|||||||
let result = run_agent_loop(
|
let result = run_agent_loop(
|
||||||
&llm, &exec, &pool, &broadcast_tx,
|
&llm, &exec, &pool, &broadcast_tx,
|
||||||
&project_id, &workflow_id, &wf.requirement, &workdir, &mgr,
|
&project_id, &workflow_id, &wf.requirement, &workdir, &mgr,
|
||||||
&instructions, Some(state), None,
|
&instructions, Some(state), None, &mut rx,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
let final_status = if result.is_ok() { "done" } else { "failed" };
|
let final_status = if result.is_ok() { "done" } else { "failed" };
|
||||||
@@ -608,6 +609,13 @@ fn build_execution_tools() -> Vec<Tool> {
|
|||||||
},
|
},
|
||||||
"required": ["content"]
|
"required": ["content"]
|
||||||
})),
|
})),
|
||||||
|
make_tool("wait_for_approval", "暂停执行,等待用户确认后继续。用于关键决策点,如:确认方案、确认配置变更、确认危险操作等。用户的回复会作为反馈返回。", serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"reason": { "type": "string", "description": "说明为什么需要用户确认" }
|
||||||
|
},
|
||||||
|
"required": ["reason"]
|
||||||
|
})),
|
||||||
tool_kb_search(),
|
tool_kb_search(),
|
||||||
tool_kb_read(),
|
tool_kb_read(),
|
||||||
]
|
]
|
||||||
@@ -637,6 +645,7 @@ fn build_feedback_prompt(project_id: &str, state: &AgentState, feedback: &str) -
|
|||||||
let status = match s.status {
|
let status = match s.status {
|
||||||
StepStatus::Done => " [done]",
|
StepStatus::Done => " [done]",
|
||||||
StepStatus::Running => " [running]",
|
StepStatus::Running => " [running]",
|
||||||
|
StepStatus::WaitingApproval => " [waiting]",
|
||||||
StepStatus::Failed => " [FAILED]",
|
StepStatus::Failed => " [FAILED]",
|
||||||
StepStatus::Pending => "",
|
StepStatus::Pending => "",
|
||||||
};
|
};
|
||||||
@@ -982,6 +991,7 @@ async fn run_agent_loop(
|
|||||||
instructions: &str,
|
instructions: &str,
|
||||||
initial_state: Option<AgentState>,
|
initial_state: Option<AgentState>,
|
||||||
external_tools: Option<&ExternalToolManager>,
|
external_tools: Option<&ExternalToolManager>,
|
||||||
|
event_rx: &mut mpsc::Receiver<AgentEvent>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let planning_tools = build_planning_tools();
|
let planning_tools = build_planning_tools();
|
||||||
let mut execution_tools = build_execution_tools();
|
let mut execution_tools = build_execution_tools();
|
||||||
@@ -1134,6 +1144,64 @@ async fn run_agent_loop(
|
|||||||
state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, "Scratchpad 已更新。"));
|
state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, "Scratchpad 已更新。"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"wait_for_approval" => {
|
||||||
|
let reason = args["reason"].as_str().unwrap_or("等待确认");
|
||||||
|
|
||||||
|
// Mark step as WaitingApproval
|
||||||
|
if let Some(step) = state.steps.iter_mut().find(|s| s.order == cur) {
|
||||||
|
step.status = StepStatus::WaitingApproval;
|
||||||
|
}
|
||||||
|
let _ = broadcast_tx.send(WsMessage::PlanUpdate {
|
||||||
|
workflow_id: workflow_id.to_string(),
|
||||||
|
steps: plan_infos_from_state(&state),
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
save_state_snapshot(pool, workflow_id, cur, &state).await;
|
||||||
|
log_execution(pool, broadcast_tx, workflow_id, cur, "wait_for_approval", reason, reason, "waiting").await;
|
||||||
|
|
||||||
|
tracing::info!("[workflow {}] Waiting for approval: {}", workflow_id, reason);
|
||||||
|
|
||||||
|
// Block until we receive a Comment event
|
||||||
|
let approval_content = loop {
|
||||||
|
match event_rx.recv().await {
|
||||||
|
Some(AgentEvent::Comment { content, .. }) => break content,
|
||||||
|
Some(_) => continue,
|
||||||
|
None => return Err(anyhow::anyhow!("Event channel closed while waiting for approval")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("[workflow {}] Approval received: {}", workflow_id, approval_content);
|
||||||
|
|
||||||
|
// Resume: restore Running status
|
||||||
|
if let Some(step) = state.steps.iter_mut().find(|s| s.order == cur) {
|
||||||
|
step.status = StepStatus::Running;
|
||||||
|
step.user_feedbacks.push(approval_content.clone());
|
||||||
|
}
|
||||||
|
let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?")
|
||||||
|
.bind(workflow_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate {
|
||||||
|
workflow_id: workflow_id.to_string(),
|
||||||
|
status: "executing".into(),
|
||||||
|
});
|
||||||
|
let _ = broadcast_tx.send(WsMessage::PlanUpdate {
|
||||||
|
workflow_id: workflow_id.to_string(),
|
||||||
|
steps: plan_infos_from_state(&state),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.current_step_chat_history.push(
|
||||||
|
ChatMessage::tool_result(&tc.id, &format!("用户已确认。反馈: {}", approval_content))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
"update_requirement" => {
|
"update_requirement" => {
|
||||||
let new_req = args["requirement"].as_str().unwrap_or("");
|
let new_req = args["requirement"].as_str().unwrap_or("");
|
||||||
let _ = sqlx::query("UPDATE workflows SET requirement = ? WHERE id = ?")
|
let _ = sqlx::query("UPDATE workflows SET requirement = ? WHERE id = ?")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub enum AgentPhase {
|
|||||||
pub enum StepStatus {
|
pub enum StepStatus {
|
||||||
Pending,
|
Pending,
|
||||||
Running,
|
Running,
|
||||||
|
WaitingApproval,
|
||||||
Done,
|
Done,
|
||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
@@ -120,7 +121,7 @@ impl AgentState {
|
|||||||
/// 全部 Done 时返回 None。
|
/// 全部 Done 时返回 None。
|
||||||
pub fn first_actionable_step(&self) -> Option<i32> {
|
pub fn first_actionable_step(&self) -> Option<i32> {
|
||||||
self.steps.iter()
|
self.steps.iter()
|
||||||
.find(|s| matches!(s.status, StepStatus::Pending | StepStatus::Running))
|
.find(|s| matches!(s.status, StepStatus::Pending | StepStatus::Running | StepStatus::WaitingApproval))
|
||||||
.map(|s| s.order)
|
.map(|s| s.order)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ impl AgentState {
|
|||||||
let marker = match s.status {
|
let marker = match s.status {
|
||||||
StepStatus::Done => " done",
|
StepStatus::Done => " done",
|
||||||
StepStatus::Running => " >> current",
|
StepStatus::Running => " >> current",
|
||||||
|
StepStatus::WaitingApproval => " ⏳ waiting",
|
||||||
StepStatus::Failed => " FAILED",
|
StepStatus::Failed => " FAILED",
|
||||||
StepStatus::Pending => "",
|
StepStatus::Pending => "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ref, nextTick } from 'vue'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
quotes: string[]
|
quotes: string[]
|
||||||
|
waitingApproval?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -50,7 +51,10 @@ defineExpose({ focusInput })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="comment-section">
|
<div class="comment-section" :class="{ 'waiting-approval': waitingApproval }">
|
||||||
|
<div v-if="waitingApproval" class="approval-banner">
|
||||||
|
⏳ Agent 正在等待你的确认,请在下方输入反馈后发送
|
||||||
|
</div>
|
||||||
<div v-if="quotes.length" class="quotes-bar">
|
<div v-if="quotes.length" class="quotes-bar">
|
||||||
<div v-for="(q, i) in quotes" :key="i" class="quote-chip">
|
<div v-for="(q, i) in quotes" :key="i" class="quote-chip">
|
||||||
<span class="quote-text">{{ q.length > 60 ? q.slice(0, 60) + '...' : q }}</span>
|
<span class="quote-text">{{ q.length > 60 ? q.slice(0, 60) + '...' : q }}</span>
|
||||||
@@ -78,6 +82,20 @@ defineExpose({ focusInput })
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-section.waiting-approval {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(79, 195, 247, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-banner {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(79, 195, 247, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.quotes-bar {
|
.quotes-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ async function onSubmitComment(text: string) {
|
|||||||
ref="commentRef"
|
ref="commentRef"
|
||||||
:disabled="!workflow"
|
:disabled="!workflow"
|
||||||
:quotes="quotes"
|
:quotes="quotes"
|
||||||
|
:waitingApproval="workflow?.status === 'waiting_approval'"
|
||||||
@submit="onSubmitComment"
|
@submit="onSubmitComment"
|
||||||
@removeQuote="removeQuote"
|
@removeQuote="removeQuote"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user