重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复
- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
This commit is contained in:
@@ -25,7 +25,62 @@ use super::audit::process_tool_calls;
|
||||
use super::{AiChatEvent, AiSession};
|
||||
|
||||
/// Agentic 循环最大迭代次数
|
||||
pub(crate) const MAX_AGENT_ITERATIONS: usize = 10;
|
||||
///
|
||||
/// 默认 10 轮。未来可配置接入点:接入 AppState(新增 `agent_config` 字段)/df-storage settings KV
|
||||
/// 表后,改为从配置读(默认值仍为 10)。当前项目无 agent 配置位(AppState/df-storage config 表
|
||||
/// 均无 agent 配置槽),故暂以常量承载,避免引入 AppState 新字段等大改(违反 P2 零行为变边界)。
|
||||
/// 接入路径:run_agentic_loop 签名增 `max_iterations: usize` 参数,调用方从 AppState 读取透传。
|
||||
pub const MAX_AGENT_ITERATIONS: usize = 10;
|
||||
|
||||
// ============================================================
|
||||
// B-260615-09: generating 状态 RAII guard
|
||||
// ============================================================
|
||||
|
||||
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
|
||||
///
|
||||
/// 两路复位:
|
||||
/// - 正常路径:exit 点显式 `reset().await` 即时复位(emit 前调,保证"复位→emit"顺序,
|
||||
/// 前端收事件时后端已可接下一条)。
|
||||
/// - 异常路径(panic/未走正常 return):Drop 兜底 spawn 复位,防 generating 永真卡死前端。
|
||||
///
|
||||
/// 注:try_continue_agent_loop 不用 guard——其 should_continue=false 路径需保持
|
||||
/// generating=true(审批等待态),全函数 guard 会误复位;该函数单点 provider-Err 复位保持手动。
|
||||
struct GeneratingGuard {
|
||||
session: Arc<Mutex<AiSession>>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl GeneratingGuard {
|
||||
fn new(session: Arc<Mutex<AiSession>>) -> Self {
|
||||
Self { session, done: false }
|
||||
}
|
||||
|
||||
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。
|
||||
async fn reset(&mut self) {
|
||||
if !self.done {
|
||||
self.session.lock().await.generating = false;
|
||||
self.done = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用:
|
||||
/// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。
|
||||
/// (B-260615-26: 修复审批执行后对话不续生成回归)
|
||||
fn disarm(&mut self) {
|
||||
self.done = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GeneratingGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.done {
|
||||
let session = self.session.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
session.lock().await.generating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环
|
||||
///
|
||||
@@ -44,11 +99,44 @@ pub(crate) async fn run_agentic_loop(
|
||||
knowledge_config: crate::state::KnowledgeConfig,
|
||||
llm_concurrency: LlmConcurrency,
|
||||
) {
|
||||
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
|
||||
&provider_config.provider_type,
|
||||
&provider_config.base_url,
|
||||
&provider_config.api_key,
|
||||
&provider_config.default_model,
|
||||
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
||||
let mut guard = GeneratingGuard::new(session_arc.clone());
|
||||
|
||||
// FR-S1: resolve→ensure_resolved_key(空 key 早失败)→build_provider 三步统一走工厂
|
||||
// 空 key 早失败(逻辑见 secret::ensure_resolved_key 单测):避免空 key 发请求吃 401,错误伪装成"API Key 无效"
|
||||
//
|
||||
// B-260615-17:resolve 一次复用——原实现 build_provider_for 成功后又独立调 resolve_provider_secret
|
||||
// 取 key_len(重复 keyring resolve)。现 resolve 一次:既供 key_len 诊断日志,又供 build_provider,
|
||||
// 去重复 keyring resolve 调用。逻辑等价于 secret::build_provider_for(resolve→ensure→build 三步),
|
||||
// 仅因 build_provider_for 隐藏 resolved key 无法复用而在此内联(未改 secret.rs 锁边界)。
|
||||
let resolved_key = super::secret::resolve_provider_secret(&provider_config);
|
||||
let key_len = resolved_key.len();
|
||||
let provider: Box<dyn LlmProvider> = match super::secret::ensure_resolved_key(
|
||||
&provider_config.name, &resolved_key,
|
||||
) {
|
||||
Ok(()) => df_ai::build_provider(
|
||||
&provider_config.provider_type,
|
||||
&provider_config.base_url,
|
||||
&resolved_key,
|
||||
&provider_config.default_model,
|
||||
),
|
||||
Err(msg) => {
|
||||
guard.reset().await;
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: msg,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
// 诊断日志:401/错误时据此定位是 url/type/model/key 哪项问题(只记长度不记明文)
|
||||
tracing::info!(
|
||||
provider = %provider_config.name,
|
||||
provider_type = %provider_config.provider_type,
|
||||
base_url = %provider_config.base_url,
|
||||
model = %provider_config.default_model,
|
||||
key_len = key_len,
|
||||
"[ai] 发起 LLM 请求"
|
||||
);
|
||||
let tool_defs = tools_arc.tool_definitions();
|
||||
// 停止信号副本:stream_llm 与每轮迭代共享读取,避免重复加锁
|
||||
@@ -57,6 +145,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
// token 累加器:loop 生命周期内各轮叠加,退出时传 save_conversation(累加模式落库)
|
||||
let mut tokens = TokenAccumulator::default();
|
||||
|
||||
// 收敛标志:仅当 LLM 末轮无 tool_calls 自行 break(正常收敛)时置 true;
|
||||
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
|
||||
let mut converged = false;
|
||||
|
||||
for iteration in 0..MAX_AGENT_ITERATIONS {
|
||||
// 用户请求停止 → 收尾退出(已生成文本已在上一轮入库)
|
||||
if stop_flag.load(Ordering::SeqCst) {
|
||||
@@ -69,13 +161,27 @@ pub(crate) async fn run_agentic_loop(
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&usage), None).await;
|
||||
// 标题生成后台化:不阻塞 Completed emit(失败有 extract_title 兜底)
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
let mut session = session_arc.lock().await;
|
||||
session.generating = false;
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
|
||||
return;
|
||||
}
|
||||
|
||||
// B-260615-11: 旧 loop 污染防护——每轮开始校验对话一致性。
|
||||
// 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧,
|
||||
// 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。
|
||||
{
|
||||
let session = session_arc.lock().await;
|
||||
if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) {
|
||||
tracing::warn!(
|
||||
stale_conv = %conv_id,
|
||||
active_conv = ?session.active_conversation_id,
|
||||
"[ai] 对话已切换,旧 loop 退出(B-260615-11)避免污染新对话"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 新一轮通知前端(第二轮起),前端需新建 assistant 消息
|
||||
if iteration > 0 {
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||
@@ -119,8 +225,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
Some(result) => result,
|
||||
None => {
|
||||
// 错误已在 stream_llm 中 emit,直接结束
|
||||
let mut session = session_arc.lock().await;
|
||||
session.generating = false;
|
||||
guard.reset().await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -136,6 +241,16 @@ pub(crate) async fn run_agentic_loop(
|
||||
let has_tool_calls = !tool_calls_acc.is_empty();
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
// B-260615-11: push 前再校验(stream_llm 期间用户可能新建对话)。
|
||||
// 读端读到被 clear 的空历史不致命,但 push 写回新对话是污染,必须挡。
|
||||
if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) {
|
||||
tracing::warn!(
|
||||
stale_conv = %conv_id,
|
||||
active_conv = ?session.active_conversation_id,
|
||||
"[ai] stream 后对话已切换,丢弃本轮 push(B-260615-11)避免污染新对话"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if has_tool_calls {
|
||||
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
|
||||
order.sort_unstable();
|
||||
@@ -165,15 +280,14 @@ pub(crate) async fn run_agentic_loop(
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
|
||||
// 标题生成后台化:不阻塞 Completed emit(失败有 extract_title 兜底)
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
let mut session = session_arc.lock().await;
|
||||
session.generating = false;
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
|
||||
return;
|
||||
}
|
||||
|
||||
// 无工具调用 → 最终文本响应,循环结束
|
||||
if !has_tool_calls { break; }
|
||||
// 无工具调用 → 最终文本响应,正常收敛退出
|
||||
if !has_tool_calls { converged = true; break; }
|
||||
|
||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
||||
let pending_count = {
|
||||
@@ -189,12 +303,30 @@ pub(crate) async fn run_agentic_loop(
|
||||
total_tokens: tokens.total(),
|
||||
};
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
|
||||
// B-260615-26: 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成,
|
||||
// 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续)
|
||||
guard.disarm();
|
||||
return; // generating 保持 true
|
||||
}
|
||||
|
||||
// 全部自动执行完成 → 继续下一轮
|
||||
}
|
||||
|
||||
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):异常中断,提示用户
|
||||
// 与 break 正常收敛(break→converged=true)区分:这里仍走入库+Completed,但前置发 AiError 警示
|
||||
if !converged {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
max_iter = MAX_AGENT_ITERATIONS,
|
||||
"[ai] agentic 循环达最大轮次(MAX_AGENT_ITERATIONS={})仍未收敛,可能未完成",
|
||||
MAX_AGENT_ITERATIONS,
|
||||
);
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: format!("达到最大轮次({} 轮),Agent 可能未完成(末轮工具结果未回传模型)", MAX_AGENT_ITERATIONS),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// 正常完成
|
||||
let usage = df_ai::provider::TokenUsage {
|
||||
prompt_tokens: tokens.prompt(),
|
||||
@@ -224,24 +356,72 @@ pub(crate) async fn run_agentic_loop(
|
||||
});
|
||||
}
|
||||
|
||||
let mut session = session_arc.lock().await;
|
||||
session.generating = false;
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
///
|
||||
/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。
|
||||
/// 各 return 点的语义判断:
|
||||
/// 1) should_continue=false(generating 已复位 / pending_approvals 非空):
|
||||
/// - generating=false → 用户点了停止(ai_chat_stop 复位)或会话已结束,emit AiCompleted 标当前轮收敛
|
||||
/// (streaming=true 由 AiCompleted 清理)
|
||||
/// - pending_approvals 非空 → 转入审批等待态(其他审批未决),emit AiCompleted 标当前轮结束
|
||||
/// (前端审批态 watchdog 已 clear,不卡)
|
||||
/// 2) get_active_provider Err → 无可用 provider(配置丢失/全删),无法续生成,emit AiError
|
||||
/// (语义:配置错误,用户需设 provider;非 generating 复位可恢复)
|
||||
pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
|
||||
let should_continue = {
|
||||
let (is_generating, has_pending) = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.generating && session.pending_approvals.is_empty()
|
||||
(session.generating, !session.pending_approvals.is_empty())
|
||||
};
|
||||
let should_continue = is_generating && !has_pending;
|
||||
|
||||
if !should_continue { return; }
|
||||
if !should_continue {
|
||||
// generating=false(被 stop)或仍有审批(pending_approvals 非空):
|
||||
// 统一 emit AiCompleted 标当前轮收敛,清前端 streaming。
|
||||
// 轮 token 已在前序 AiCompleted/AiApprovalResult 流程落库,此处零 token 上报仅作收敛信号。
|
||||
if is_generating {
|
||||
// pending_approvals 非空但 generating 仍 true:转审批态,前端审批态 watchdog 已 clear,不卡
|
||||
tracing::info!("[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
||||
} else {
|
||||
// generating 已复位(用户 stop 或前序循环已 emit Completed):补发 AiCompleted 防前端卡住
|
||||
tracing::info!("[ai] try_continue 跳过:generating 已复位(被 stop/已结束),补发 AiCompleted 清前端 streaming");
|
||||
let conv_id = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.active_conversation_id.clone().unwrap_or_default()
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
conversation_id: Some(conv_id),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_config = match get_active_provider(state).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
Err(e) => {
|
||||
// 无可用 provider(配置丢失/全删):无法续生成,emit AiError。
|
||||
// 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内
|
||||
// build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。
|
||||
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保 generating=true(审批等待态),
|
||||
// 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.generating = false;
|
||||
let conv_id = session.active_conversation_id.clone().unwrap_or_default();
|
||||
drop(session);
|
||||
tracing::warn!(error = %e, "[ai] try_continue 失败:无可用 provider");
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: e,
|
||||
conversation_id: Some(conv_id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (lang, conv_id) = {
|
||||
let session = state.ai_session.lock().await;
|
||||
|
||||
Reference in New Issue
Block a user