//! Agentic 循环 — 流式接收 → 工具执行 → 结果回传 LLM → 循环 use std::sync::Arc; use std::sync::atomic::Ordering; use tauri::{AppHandle, Emitter}; use tokio::sync::Mutex; use df_ai::ai_tools::AiToolRegistry; use df_ai::context::TokenEstimator; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; // CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类) // 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。 use df_ai::retry; use df_storage::db::Database; use df_storage::models::AiProviderRecord; use crate::state::{AppState, LlmConcurrency}; use super::conversation::{save_conversation, TokenAccumulator}; use super::knowledge_inject::maybe_spawn_extraction; use super::prompt::{build_system_prompt, get_active_provider}; use super::stream_recv::{stream_llm, StreamResult}; use super::title::{ensure_conversation_title, spawn_ensure_title}; use super::audit::process_tool_calls; use super::{AiChatEvent, AiSession, ErrorType}; /// Agentic 循环默认最大迭代次数(可配置项的默认值) /// /// 默认 10 轮。F-260616-01 已接入配置:AppState.agent_max_iterations(Arc) + /// ai_set_agent_max_iterations command + Settings.vue 数字配置。调用方在 loop 入口 /// load AtomicUsize 快照后透传 `max_iterations: usize` 形参,当前 loop 锁定边界, /// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。 pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10; /// 流式对话失败自动重试默认次数(F-260616-07 / 决策 a1) /// /// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) + /// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。 /// 只重试流前失败(Init Err:未输出任何 token);流中途失败(MidStream:已输出 /// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。 pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3; // ============================================================ // 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>, done: bool, } impl GeneratingGuard { fn new(session: Arc>) -> 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 → 循环 /// /// 退出条件: /// - LLM 只返回文本(无 tool_calls)→ 正常结束 /// - 有工具需要审批 → 暂停循环(generating 保持 true),等 ai_approve 恢复 /// - 达到最大迭代次数 → 正常结束 pub(crate) async fn run_agentic_loop( session_arc: Arc>, tools_arc: Arc, db: Arc, app_handle: AppHandle, provider_config: AiProviderRecord, system_prompt: String, conv_id: String, knowledge_config: crate::state::KnowledgeConfig, llm_concurrency: LlmConcurrency, max_iterations: usize, max_retries: usize, ) { // 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 = 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, // ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth error_type: Some(ErrorType::Auth), 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 与每轮迭代共享读取,避免重复加锁 // notify 同取一份 Arc 引用(B-260615-14):stream_llm select! 监听 notified() 即时唤醒 let (stop_flag, notify) = { let session = session_arc.lock().await; (session.stop_flag.clone(), session.notify.clone()) }; // 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_iterations { // 用户请求停止 → 收尾退出(已生成文本已在上一轮入库) if stop_flag.load(Ordering::SeqCst) { let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), }; // 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组 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); 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(), incomplete: None, 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 { round: (iteration + 1) as u32, conversation_id: Some(conv_id.clone()), }); } // 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条) let messages = { let session = session_arc.lock().await; let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens); let mut msgs = vec![ChatMessage::system(&system_prompt)]; msgs.extend(history_msgs); msgs }; // 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补) // 注:F-260616-07 重试循环内每次重建 retry_request(因 provider.stream 消费 body), // 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。 let estimated_prompt: u32 = { let est = TokenEstimator::default(); messages.iter().map(|m| est.estimate_message(m)).sum() }; // LLM 并发限流(全局 + 单对话双层),仅覆盖 stream_llm 调用本身; // 工具执行(process_tool_calls)是本地操作无 RPM 成本,permit 在 stream 后立即释放避免占槽 // // CR-30-1 / F-260616-07 / 决策 a1: 流前失败(Init Err)重试,流中途失败(MidStream // Partial)不重试保文。重试退避复用 retry::backoff_delay(1s→2s→4s±20% jitter) + // retry::is_status_retryable Fatal 分类(stream_recv classify_status_or_class 镜像, // 4xx 非429 立即放弃) + 30s 总挂钟预算。重试期间持有 permit 不释放(防新请求挤占)。 let _global_permit = llm_concurrency.acquire_global().await; let _per_conv_permit = llm_concurrency.acquire_per_conv().await; // 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。 // 超预算直接放弃重试交最终错误/保文路径。 let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); let (full_text, tool_calls_acc, round_usage, incomplete) = { // outcome 累积最后一次成功/保文结果(Complete 或 Partial)。InitFailed 不写入, // 走重试或耗尽 return。 let mut outcome: Option<(String, std::collections::HashMap, df_ai::provider::TokenUsage, bool)> = None; for retry_attempt in 0..=max_retries { // 每次重试重建 request(CompletionRequest 无状态,但 provider.stream() 内部可能消费 body) let retry_request = CompletionRequest { model: provider_config.default_model.clone(), messages: { let session = session_arc.lock().await; let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens); let mut msgs = vec![ChatMessage::system(&system_prompt)]; msgs.extend(history_msgs); msgs }, temperature: Some(0.7), max_tokens: Some(8192), stream: true, tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) }, tool_choice: None, }; match stream_llm(&*provider, retry_request, &app_handle, &stop_flag, ¬ify, &conv_id).await { StreamResult::Complete { text, tool_calls, usage } => { // 正常完成(流尽 + finished,或用户主动停止)。incomplete=false。 outcome = Some((text, tool_calls, usage, false)); break; } StreamResult::Partial { text, tool_calls, usage } => { // CR-30-2 / UX-2025-04 / 决策 a1: 流中途失败保文,**不重试**。 // 已 emit AiTextDelta(前端 currentText 已累积),此处保文入库 + // AiCompleted(incomplete=true) + 系统提示。currentText 不混乱(未重试无追加)。 tracing::warn!( conv_id = %conv_id, text_len = text.len(), "[ai] 流中途失败,保文不重试(incomplete=true),入库 + AiCompleted + 系统提示", ); outcome = Some((text, tool_calls, usage, true)); break; } StreamResult::InitFailed { retryable } => { // stream_llm 已 emit AiError。决定是否重试(仅 Init 失败可重试,决策 a1)。 let is_last = retry_attempt >= max_retries; let now = tokio::time::Instant::now(); let budget_exhausted = now >= retry_deadline; // Fatal(4xx 非429/鉴权/参数错)立即放弃,不浪费预算重试 if !retryable { tracing::warn!( conv_id = %conv_id, attempt = retry_attempt + 1, "[ai] 流前失败 Fatal(4xx/鉴权),立即放弃不重试", ); guard.reset().await; return; } if is_last || budget_exhausted { // 重试耗尽或预算耗尽:错误已 emit,结束 tracing::warn!( conv_id = %conv_id, attempt = retry_attempt + 1, budget_exhausted = budget_exhausted, "[ai] 流前失败重试{}({}次),放弃", if budget_exhausted { "预算耗尽" } else { "耗尽" }, max_retries + 1, ); guard.reset().await; return; } // CR-30-1: 退避复用 retry::backoff_delay(attempt+1)(1s→2s→4s + ±20% jitter), // 不再使用纯指数 `1u64 << retry_attempt`。attempt 传 retry_attempt+1 // (retry::backoff_delay 是 1-based,第 1 次重试 ~1s,第 2 次 ~2s,第 3 次 ~4s)。 let delay = retry::backoff_delay((retry_attempt + 1) as u32) .min(retry_deadline.saturating_duration_since(now)); let total_retry = retry_attempt + 1; // 1-based 当前是第几次尝试 tracing::warn!( conv_id = %conv_id, attempt = total_retry, max_attempts = max_retries + 1, delay_ms = delay.as_millis() as u64, "[ai] 流前失败(Retryable),{}ms 后重试 ({}/{})", delay.as_millis(), total_retry, max_retries + 1 ); // F-260616-07(d): emit 重试提示事件,前端在错误气泡内显示「重试 n/m」 // (对齐决策"错误气泡内更新")。CR-30-2: useAiEvents.ts 补 case 处理。 let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiStreamRetry { attempt: total_retry as u32, max_attempts: (max_retries + 1) as u32, conversation_id: Some(conv_id.clone()), }); tokio::time::sleep(delay).await; continue; } } } match outcome { Some(r) => r, None => { // 不应到达:InitFailed 非 Fatal/未耗尽即重试,耗尽/Fatal 已 return; // Complete/Partial 写入 outcome 后 break。防御兜底。 guard.reset().await; return; } } }; // CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收, // 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。 // 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。 if incomplete { let usage = df_ai::provider::TokenUsage { prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens }, completion_tokens: round_usage.completion_tokens, total_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt + round_usage.completion_tokens } else { round_usage.total_tokens }, }; tokens.add(usage.prompt_tokens, usage.completion_tokens); // 追加 partial assistant 消息(若无 tool_calls 且有文本) { let mut 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] MidStream 保文后对话已切换,丢弃本轮 push(B-260615-11)" ); return; } if !full_text.is_empty() { let mut msg = ChatMessage::assistant(&full_text); msg.model = Some(provider_config.default_model.clone()); session.messages.push(msg); // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制) let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"); notice.model = Some(provider_config.default_model.clone()); session.messages.push(notice); } } save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await; // 标题生成后台化(失败有 extract_title 兜底) spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); guard.reset().await; // generating 复位后再 emit Completed(incomplete=true):前端据此标记消息为不完整 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()), incomplete: Some(true), }); return; } // stream 结束立即释放 permit,后续工具执行不受限流(本地操作无 RPM 成本) drop(_global_permit); drop(_per_conv_permit); // 累加本轮 token:provider 流式 usage 的 prompt_tokens 为 0 时(GLM 等),用预估输入兜底 let round_prompt = if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens }; tokens.add(round_prompt, round_usage.completion_tokens); // 追加 assistant 消息到历史 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 = tool_calls_acc.keys().copied().collect(); order.sort_unstable(); let ai_tool_calls: Vec = order.iter() .map(|i| { let draft = &tool_calls_acc[i]; df_ai::provider::ToolCall::new(&draft.id, &draft.name, &draft.args) }) .collect(); let mut msg = ChatMessage::assistant_with_tools(&full_text, ai_tool_calls); msg.model = Some(provider_config.default_model.clone()); session.messages.push(msg); } else if !full_text.is_empty() { let mut msg = ChatMessage::assistant(&full_text); msg.model = Some(provider_config.default_model.clone()); session.messages.push(msg); } } // 停止信号:已生成文本入库后退出,不再执行后续工具调用 if stop_flag.load(Ordering::SeqCst) { let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), }; 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); 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(), incomplete: None, conversation_id: Some(conv_id.clone()) }); return; } // 无工具调用 → 最终文本响应,正常收敛退出 if !has_tool_calls { converged = true; break; } // 处理工具调用(Low 自动执行 / Medium+High 待审批) let pending_count = { let mut session = session_arc.lock().await; process_tool_calls(&mut session, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await }; // 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复 if pending_count > 0 { let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), 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):转入暂停态询问用户 // F-260616-03:不再 emit AiError + 走完成流程,改为 emit AiMaxRoundsReached + 保持 generating=true // (仿审批等待 L313-316),等用户点继续(ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮) // 或点停止(ai_stop_loop → 走完成流程)。try_continue 重新 spawn run_agentic_loop,iteration 从 0 重计, // 故续跑天然再跑 max_iterations 轮(决策 a),无需 reset 任何计数器。 if !converged { tracing::warn!( conv_id = %conv_id, max_iter = max_iterations, "[ai] agentic 循环达最大轮次(max_iterations={})仍未收敛,转暂停态询问用户(F-260616-03)", max_iterations, ); // 轮 token 落库(保留末轮已生成内容,续跑/停止都据此累加) let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), }; save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await; // 暂停态保持 generating=true(防其他 send 抢占,仿审批),disarm guard 跳过 Drop 兜底复位 guard.disarm(); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiMaxRoundsReached { conversation_id: Some(conv_id.clone()), }); return; // generating 保持 true,等 ai_continue_loop / ai_stop_loop } // 正常完成 let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), total_tokens: tokens.total(), }; // 落库 + 标题 + 知识提炼打包后台化:不阻塞 generating 复位与 Completed 事件 // save 先行(extract/title 都读已落库消息);extract 内部 fire-and-forget,与 title 可能并发 // (均受 per_conv 信号量约束,读写不同字段互不干扰) // 并发取舍:与新对话新 loop 的 save 存在低概率并发 upsert,最多丢少量 token 累加(非功能错误,可接受) let usage_total = usage.total_tokens; { let session_arc = session_arc.clone(); let db = db.clone(); let conv_id = conv_id.clone(); let provider_config = provider_config.clone(); let knowledge_config = knowledge_config.clone(); let app_handle = app_handle.clone(); let llm_concurrency = llm_concurrency.clone(); tauri::async_runtime::spawn(async move { save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await; // 知识提炼:需读已落库的对话消息,故在 save 之后 if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await { tracing::warn!("知识提炼触发失败(非阻断): {}", e); } ensure_conversation_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, llm_concurrency).await; }); } 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(), incomplete: None, 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 复位可恢复) /// /// R-PD-6: conv_id 来源从全局 active_conversation_id 解耦到审批所属会话。 /// 触发本函数的 ai_approve 已 remove 触发审批,但 pending_approvals 内剩余审批(若 has_pending) /// 仍各自携带 conversation_id(审批产生时由 process_tool_calls 写入,业务真相源)。 /// 故 has_pending=true 分支(审批等待态)直接取剩余审批的 conversation_id 做 conv_id, /// 不读 active_conversation_id 全局单例——该字段在审批等待态(非 generating-only 期)可被 /// ai_chat_stop/clear/switch 并发改写,属竞态耦合。has_pending=false(全部审批已处理,续生成) /// 分支:审批已被 remove,改为以剩余 pending_approvals 任一 conversation_id 做一致性校验 /// (此处空,校验通过即沿用全局值,该期 generating=true 且 switch 为 readonly 不并发)。 pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) { let (is_generating, has_pending, pending_conv_id) = { let session = state.ai_session.lock().await; // pending_approvals 中任一审批的 conversation_id:审批等待态(has_pending)下作为 conv_id 来源, // 取第一个非空值(同一对话的审批 conversation_id 一致,见 process_tool_calls 写入路径)。 let pending_conv_id = session.pending_approvals.values() .find_map(|a| a.conversation_id.clone()); (session.generating, !session.pending_approvals.is_empty(), pending_conv_id) }; let should_continue = is_generating && !has_pending; 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"); // R-PD-6: 优先用审批所属 conversation_id(审批等待态被 stop 触发,审批仍在 pending_approvals), // 仅当无任何审批(has_pending=false 且 generating=false)时回退 active_conversation_id。 let conv_id = match pending_conv_id { Some(cid) => cid, None => { 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, incomplete: None, conversation_id: Some(conv_id), }); } return; } let provider_config = match get_active_provider(state).await { Ok(p) => p, 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; // R-PD-6: 续生成被拒(provider 缺失)回退 conv_id 优先审批所属;无审批再读全局。 let conv_id = session.pending_approvals.values() .find_map(|a| a.conversation_id.clone()) .or_else(|| 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, // 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig error_type: Some(ErrorType::ProviderConfig), conversation_id: Some(conv_id), }); return; } }; // R-PD-6: 续生成路径 conv_id 解耦——has_pending=false 时审批已 remove,无审批 conversation_id 可取; // 此期 generating=true 且 switchConversation 为 readonly 不并发改 active_conversation_id, // 故读全局值安全(非竞态期);若 has_pending=true 已在上面 return,不会到此。 let (lang, conv_id) = { let session = state.ai_session.lock().await; let lang = session.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string()); let conv_id = session.active_conversation_id.clone().unwrap_or_default(); (lang, conv_id) }; let system_prompt = build_system_prompt(state, &lang).await; let session_arc = state.ai_session.clone(); let tools_arc = state.ai_tools.clone(); let db = state.db.clone(); let app_handle = app.clone(); let knowledge_config = state.knowledge_config.lock().await.clone(); let llm_concurrency = state.llm_concurrency.clone(); // F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效) let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); // F-260616-07: 流式失败重试次数快照 let max_retries = state.agent_max_retries.load(Ordering::SeqCst); // 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本 // 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开 let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound { round: 0, conversation_id: Some(conv_id.clone()), }); tauri::async_runtime::spawn(async move { run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await; }); }