重构: AI聊天可靠性批次(审批累计计数/write_file diff预览/会话隔离补清/token缓存)

This commit is contained in:
2026-06-16 19:56:29 +08:00
parent ba7f35552b
commit d00b30f0ba
12 changed files with 238 additions and 45 deletions

View File

@@ -111,6 +111,7 @@ pub(crate) async fn run_agentic_loop(
llm_concurrency: LlmConcurrency,
max_iterations: usize,
max_retries: usize,
start_iteration: usize,
) {
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
let mut guard = GeneratingGuard::new(session_arc.clone());
@@ -168,7 +169,11 @@ pub(crate) async fn run_agentic_loop(
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false;
for iteration in 0..max_iterations {
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
for iteration in start_iteration..max_iterations {
// 用户请求停止 → 收尾退出(已生成文本已在上一轮入库)
if stop_flag.load(Ordering::SeqCst) {
let usage = df_ai::provider::TokenUsage {
@@ -190,7 +195,7 @@ pub(crate) async fn run_agentic_loop(
// 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧,
// 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。
{
let session = session_arc.lock().await;
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,
@@ -199,6 +204,10 @@ pub(crate) async fn run_agentic_loop(
);
return;
}
// F-260616-11 决策 a: 累计 iteration 计数(「下一轮起算值」= 当前轮+1)。
// 审批等待/达 max 暂停退出时,本字段即「当前轮+1」;审批续跑 ai_approve 读此值作
// start_iteration 透传,实现 iteration 累计不重置(防多次审批反复跑满 max 致 token 失控)。
session.iteration_used = iteration + 1;
}
// 新一轮通知前端(第二轮起),前端需新建 assistant 消息
@@ -210,9 +219,10 @@ pub(crate) async fn run_agentic_loop(
}
// 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条)
// F-260616-13: sys_tokens 已在 loop 外缓存;本块构建的 messages 在本轮重试循环中复用
// (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。
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);
@@ -248,16 +258,15 @@ pub(crate) async fn run_agentic_loop(
for retry_attempt in 0..=max_retries {
// 每次重试重建 request(CompletionRequest 无状态,但 provider.stream() 内部可能消费 body)
//
// F-260616-13: messages 复用本轮外层构建的快照(不再持 session_arc.lock() 重建)。
// 安全前提:本轮 stream_llm 不接收 session_arc、不 push 消息;重试期间 tool 执行
// (process_tool_calls)在重试块之后,故重试内 session.messages 必然与外层构建时
// 一致,重建与复用等价。收益:省去每次重试的 lock() + build_for_request(含
// all_messages_clone 全量 clone)。
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
},
messages: messages.clone(),
temperature: Some(0.7),
max_tokens: Some(8192),
stream: true,
@@ -487,8 +496,9 @@ pub(crate) async fn run_agentic_loop(
// 达 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 任何计数器。
// 或点停止(ai_stop_loop → 走完成流程)。try_continue 续跑 iteration 由调用方传 start_iteration 决定:
// 审批续跑累计(F-260616-11 决策 a,防多次审批反复跑满 max 致 token 失控,传 session.iteration_used),
// 达 max 续跑重计(F-260616-03 决策 a,用户点继续=授权重来,传 0 + 重置 iteration_used)。
if !converged {
tracing::warn!(
conv_id = %conv_id,
@@ -565,7 +575,7 @@ pub(crate) async fn run_agentic_loop(
/// 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) {
pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState, start_iteration: usize) {
let (is_generating, has_pending, pending_conv_id) = {
let session = state.ai_session.lock().await;
// pending_approvals 中任一审批的 conversation_id:审批等待态(has_pending)下作为 conv_id 来源,
@@ -662,6 +672,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
});
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;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration).await;
});
}

View File

@@ -19,6 +19,7 @@ use crate::state::AppState;
use crate::commands::{err_str, now_millis};
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
use super::tool_registry::generate_diff;
/// RiskLevel → 审计记录字符串low/medium/high
pub(crate) fn risk_str(r: RiskLevel) -> &'static str {
@@ -274,6 +275,11 @@ pub async fn restore_pending_approvals(state: &AppState) {
risk_level: risk,
conversation_id: rec.conversation_id,
recovered: true,
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
// 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态,
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
// 前端见 diff=None 时回退显新 content。
diff: None,
},
);
}
@@ -491,6 +497,28 @@ fn sort_object_keys(v: &mut serde_json::Value) {
}
}
/// AE-2025-03路径 Bwrite_file 审批预览 diff 生成。
///
/// 从 write_file args 取 path旧文件路径+ content新内容
/// 预读旧文件 → 复用 `generate_diff`F-260615-10 LCS 行级 diff注入审批事件。
/// 旧文件不存在(新建)/ 读失败 / args 缺字段 → None前端回退显新 content
///
/// **仅读不改**审批未通过前不动文件读路径不校验write_file handler 自身会校验
/// validate_path审批拒绝则 handler 不执行,恶意路径读失败仅得到 None 不产生危害)。
async fn build_write_file_diff(args: &serde_json::Value) -> Option<String> {
let path = args.get("path")?.as_str()?;
let new_content = args.get("content")?.as_str()?;
match tokio::fs::read_to_string(path).await {
Ok(old_content) => {
if old_content == new_content {
return None; // 无变化不生成 diff理论上 write_file 不会如此,容错)
}
Some(generate_diff(&old_content, new_content))
}
Err(_) => None, // 文件不存在(新建)/ 无读权限 → 无 diff前端显新 content
}
}
/// 处理流式接收的工具调用Low 风险并行执行join_allMed/High 进审批门控
/// 返回待审批的工具数量0 = 全部自动执行完成)
pub(crate) async fn process_tool_calls(
@@ -553,6 +581,15 @@ pub(crate) async fn process_tool_calls(
}
}
pending_count += 1;
// AE-2025-03路径 Bwrite_file 挂起审批前预读旧文件生成 diff。
// 仅 write_file覆盖整文件有完整新旧内容可对比其他工具 diff=None。
// 旧文件不存在(新建)→ diff=None前端回退显新 content。
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
let approval_diff: Option<String> = if draft.name == "write_file" {
build_write_file_diff(&args).await
} else {
None
};
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
tool_call_id: draft.id.clone(),
tool_name: draft.name.clone(),
@@ -560,6 +597,7 @@ pub(crate) async fn process_tool_calls(
risk_level,
conversation_id: Some(conv_id.to_string()),
recovered: false,
diff: approval_diff.clone(),
});
session.messages.push(ChatMessage::tool_result(&draft.id, "需要用户审批,等待确认"));
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
@@ -568,6 +606,7 @@ pub(crate) async fn process_tool_calls(
name: draft.name.clone(),
args: args.clone(),
reason,
diff: approval_diff,
conversation_id: Some(conv_id.to_string()),
});
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None).await;

View File

@@ -51,6 +51,8 @@ pub async fn ai_regenerate(
session.generating = true;
session.stop_flag.store(false, Ordering::SeqCst);
session.agent_language = language.clone();
// F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。
session.iteration_used = 0;
let popped = session.messages.pop_last_assistant_round();
if !popped {
// 历史末尾无 AI 回复可弹(空对话/末尾是 user 错误态等),复位 generating 报错
@@ -105,7 +107,7 @@ pub async fn ai_regenerate(
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
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;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0).await;
});
Ok("ok".to_string())
@@ -146,6 +148,8 @@ pub async fn ai_chat_send(
session.generating = true;
session.stop_flag.store(false, Ordering::SeqCst);
session.agent_language = language.clone();
// F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。
session.iteration_used = 0;
session.messages.push(ChatMessage::user(&message));
// 首次发送时生成对话 id(懒创建:不立即落库,避免空对话残留;
@@ -201,7 +205,7 @@ pub async fn ai_chat_send(
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
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;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0).await;
});
Ok("ok".to_string())
@@ -251,8 +255,11 @@ pub async fn ai_approve(
}
// 审计:拒绝(决策者=human
audit_finalize(&state, &tool_call_id, "rejected", None).await;
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 session.iteration_used 透传
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
let start_iter = state.ai_session.lock().await.iteration_used;
// 所有待审批处理完毕后恢复 agentic 循环
try_continue_agent_loop(&app, &state).await;
try_continue_agent_loop(&app, &state, start_iter).await;
return Ok("rejected".to_string());
}
@@ -301,8 +308,11 @@ pub async fn ai_approve(
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
}
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 session.iteration_used 透传
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
let start_iter = state.ai_session.lock().await.iteration_used;
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
try_continue_agent_loop(&app, &state).await;
try_continue_agent_loop(&app, &state, start_iter).await;
Ok("executed".to_string())
}
@@ -398,6 +408,8 @@ pub async fn ai_chat_edit(
session.generating = true;
session.stop_flag.store(false, Ordering::SeqCst);
session.agent_language = language.clone();
// F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。
session.iteration_used = 0;
}
let _tool_defs = state.ai_tools.tool_definitions();
@@ -453,6 +465,7 @@ pub async fn ai_chat_edit(
llm_concurrency,
max_iterations,
max_retries,
0,
)
.await;
});
@@ -560,8 +573,10 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
///
/// 场景run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag防上一轮残留致续跑入口即退出
/// → 调 try_continue_agent_loop 重新 spawn run_agentic_loopiteration 从 0 重计天然再跑
/// max_iterations 轮决策 a)。
/// → 调 try_continue_agent_loop 重新 spawn run_agentic_loopiteration 从 0 重计(天然再跑
/// max_iterations 轮,决策 a),F-260616-11 落地后:重置 session.iteration_used=0 + 传
/// start_iteration=0(达 max 续跑重计);审批续跑(ai_approve)则累计传 session.iteration_used,
/// 两路径区分见 run_agentic_loop 达 max 分支注释。
///
/// 校验复用 ai_approve 模式generating 必须为 true暂停态+ active_conversation_id 一致性
/// (防陈旧 loop 续跑污染新对话)。无硬上限防无限续(决策 b用户主动授权 = 同意烧 token
@@ -581,9 +596,12 @@ pub async fn ai_continue_loop(
}
// 复位停止信号:暂停态可能因上一轮 stop_flag 残留为 true续跑 loop 入口会立即退出走完成流程
session.stop_flag.store(false, Ordering::SeqCst);
// F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。
// 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。
session.iteration_used = 0;
}
// 复用审批恢复续 loop 入口(不重写 loop其内部 spawn run_agentic_loop
try_continue_agent_loop(&app, &state).await;
try_continue_agent_loop(&app, &state, 0).await;
Ok("ok".to_string())
}
@@ -836,6 +854,11 @@ pub async fn ai_conversation_create(
session.active_conv_created_at = Some(now);
session.messages.clear();
session.pending_approvals.clear();
// F-260616-09(A 路线):补漏清字段维持单例软隔离,解「新建会话上下文残留」。
// stop_flag 复位 false:上方生成中分支曾置 true 停旧 loop,不复位则新会话 loop
// 启动即见 stop_flag=true 异常退出;agent_language 清空防新会话沿用旧会话语言设置。
session.stop_flag.store(false, Ordering::SeqCst);
session.agent_language = None;
Ok(serde_json::json!({ "id": id }))
}

View File

@@ -96,7 +96,9 @@ pub enum AiChatEvent {
/// 工具调用完成
AiToolCallCompleted { id: String, result: serde_json::Value, conversation_id: Option<String> },
/// 需要人工审批
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, conversation_id: Option<String> },
/// diffAE-2025-03路径 B仅 write_file 注入(旧文件 vs 新内容行级 unified diff
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option<String>, conversation_id: Option<String> },
/// 审批结果
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
/// AI 响应完成
@@ -199,6 +201,18 @@ pub struct AiSession {
/// `Arc<Notify>` 可 CloneNotify 内部用 Atomic 计数Clone 等价于 Arc 引用 +1
/// 不影响 AiSession 其他字段语义。
pub notify: Arc<tokio::sync::Notify>,
/// agentic loop 累计已跑 iteration 计数F-260616-11 决策 a
///
/// **语义**「下一轮起算值」。run_agentic_loop 每轮写 `iteration + 1`,审批等待/
/// 达 max 暂停退出时即「当前轮+1」。续跑时由调用方决定如何接
/// - **审批续跑**ai_approve传本字段作 start_iteration**累计**,防多次审批反复
/// 跑满 max_iterations 致 token 失控);
/// - **达 max 续跑**ai_continue_loop重置本字段=0 + 传 0**重计**F-260616-03
/// 决策 a用户点继续=授权重来);
/// - **新消息首次发送**ai_chat_send重置本字段=0新对话生命周期从头计数
///
/// 单例一份:当前 AiSession 是全局单例F-09 B 多会话架构落地时改 per-conv
pub iteration_used: usize,
}
impl AiSession {
@@ -213,6 +227,7 @@ impl AiSession {
agent_language: None,
stop_flag: Arc::new(AtomicBool::new(false)),
notify: Arc::new(tokio::sync::Notify::new()),
iteration_used: 0,
}
}
@@ -261,6 +276,10 @@ pub struct PendingApproval {
pub conversation_id: Option<String>,
/// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑
pub recovered: bool,
/// AE-2025-03路径 Bwrite_file 的行级 unified diff旧文件 vs 新内容)。
/// 仅挂起审批前预读注入,供前端审批卡预览;旧文件不存在(新建)或非 write_file 为 None。
/// recovered 审批(启动重建)无 diff文件可能已变重读无意义
pub diff: Option<String>,
}
/// 工具调用草稿(流式收集时的临时结构)

View File

@@ -26,7 +26,10 @@ const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
/// 生成行级 unified diff无外部依赖基于 LCS
/// 仅标 +/- 前缀,不做 hunk header足够审批卡/审计留痕可读)。
/// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。
fn generate_diff(old: &str, new: &str) -> String {
///
/// AE-2025-03路径 Baudit.rs 挂起审批前预读旧文件复用此函数生成 diff
/// 供前端审批卡即时预览write_file 审批不再只看裸 content。故 pub(crate)。
pub(crate) fn generate_diff(old: &str, new: &str) -> String {
let a: Vec<&str> = old.lines().collect();
let b: Vec<&str> = new.lines().collect();
let (n, m) = (a.len(), b.len());