Files
DevFlow/src-tauri/src/commands/ai/agentic.rs

668 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<AtomicUsize>) +
/// 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<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 → 循环
///
/// 退出条件:
/// - LLM 只返回文本(无 tool_calls→ 正常结束
/// - 有工具需要审批 → 暂停循环generating 保持 true等 ai_approve 恢复
/// - 达到最大迭代次数 → 正常结束
pub(crate) async fn run_agentic_loop(
session_arc: Arc<Mutex<AiSession>>,
tools_arc: Arc<AiToolRegistry>,
db: Arc<Database>,
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<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,
// 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-14stream_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<u32, super::ToolCallDraft>, 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, &notify, &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<u32> = tool_calls_acc.keys().copied().collect();
order.sort_unstable();
let ai_tool_calls: Vec<df_ai::provider::ToolCall> = 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;
});
}