//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。 //! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。 use tauri::{AppHandle, Emitter, Manager}; use crate::state::AppState; use super::approval_timeout; use crate::commands::ai::prompt::{build_system_prompt, get_active_provider}; use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt; use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry}; use super::conv_state::ConvState; /// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。 struct ContinueSnapshot { is_generating: bool, has_pending: bool, pending_conv_id: Option, agent_language: Option, model_override: Option, pinned_goals_snapshot: Vec, } /// 检查是否所有待审批已处理,如果是则恢复 agentic 循环 pub async fn try_continue_agent_loop( app: &AppHandle, state: &AppState, conv_id: &str, start_iteration: usize, ) { approval_timeout::cleanup_expired_approvals(app, state, conv_id).await; let snap = { let session = state.ai_session.lock().await; let has_pending = session.session_state(conv_id, &state.conv_states) == SessionState::AwaitingApproval; let pending_conv_id = session.pending_approvals.values() .find_map(|a| a.conversation_id.clone()); let conv = session.conv_read(conv_id); // B-Phase2:ConvState 读侧切无锁 conv_states(is_active),不再占 session lock 读 conv_state。 let is_generating = state.conv_states.is_active(conv_id); let agent_language = conv.and_then(|c| c.agent_language.clone()); let model_override = conv.and_then(|c| c.model_override.clone()); let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default(); ContinueSnapshot { is_generating, has_pending, pending_conv_id, agent_language, model_override, pinned_goals_snapshot, } }; let should_continue = snap.is_generating && !snap.has_pending; if !should_continue { if snap.is_generating { tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态"); } else { tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位"); let emit_conv_id = match snap.pending_conv_id.clone() { Some(cid) => cid, None => conv_id.to_string(), }; let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, conversation_id: Some(emit_conv_id), pinned_goals: snap.pinned_goals_snapshot.clone(), }; let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.state::().ai_event_bus.publish_event(ev); } return; } let provider_config = match get_active_provider(state).await { Ok(p) => p, Err(e) => { // B-Phase3:不再持锁操作 conv_state(ConvStateStore 无锁单源)。 if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) { tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法"); } let _ = app.emit("ai-chat-event", AiChatEvent::AiError { error: e.clone(), error_type: Some(ErrorType::ProviderConfig), conversation_id: Some(conv_id.to_string()), }); let _ = app.state::().ai_event_bus.publish_event(AiChatEvent::AiError { error: e, error_type: None, conversation_id: Some(conv_id.to_string()), }); return; } }; let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string()); let conv_id_owned = conv_id.to_string(); 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(); let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await; let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst); let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst); let model_override = snap.model_override.clone(); // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。 let still_generating = state.conv_states.is_active(conv_id); if !still_generating { let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, conversation_id: Some(conv_id_owned.clone()), pinned_goals: snap.pinned_goals_snapshot.clone(), }; let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.state::().ai_event_bus.publish_event(ev); return; } let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound { round: 0, conversation_id: Some(conv_id_owned.clone()), }); let _ = app.state::().ai_event_bus.publish_event(AiChatEvent::AiAgentRound { round: 0, conversation_id: Some(conv_id_owned.clone()), }); tauri::async_runtime::spawn(async move { super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await; }); } /// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。 /// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。 pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap) -> Vec { if tool_calls.is_empty() { return vec![]; } let mut goals: Vec = Vec::new(); for tc in tool_calls.values() { let goal = match tc.name.as_str() { "patch_file" | "write_file" | "edit_file" | "append_file" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| { let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p); format!("修改 {}", f) })) } "read_file" | "file_info" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| { let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p); format!("查看 {}", f) })) } "search_files" | "grep" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("pattern").or_else(|| args.get("query")).and_then(|v| v.as_str()).map(|q| { let s = if q.len() > 25 { &q[..25] } else { q }; format!("搜索 {}", s) })) .or_else(|| Some("搜索文件".to_string())) } "run_command" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("command").and_then(|v| v.as_str()).map(|c| { let s = if c.len() > 25 { &c[..25] } else { c }; format!("执行 {}", s) })) .or_else(|| Some("执行命令".to_string())) } "create_project" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("name").and_then(|v| v.as_str()).map(|n| format!("创建项目 {}", n))) } "create_idea" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("title").and_then(|v| v.as_str()).map(|t| format!("捕获灵感 {}", t))) } "run_workflow" => Some("执行工作流".to_string()), "delete_file" | "rename_file" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| { let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p); format!("处理 {}", f) })) } "list_directory" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p))) } _ => None, }; if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } } } goals } /// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。 pub(crate) fn is_empty_tool_result(content: &str) -> bool { let trimmed = content.trim(); if trimmed.is_empty() { return true; } const EMPTY_MARKERS: &[&str] = &[ "\"total\":0", "\"entries\":[]", "\"matches\":[]", "\"results\":[]", "\"files\":[]", ]; for marker in EMPTY_MARKERS { if trimmed.contains(marker) { return true; } } const EMPTY_TEXT: &[&str] = &[ "未找到", "没有找到", "无匹配", "没有匹配", "未匹配", "未发现", "无记录", "No matches", "no matches", "0 results", "0 matches", "没有数据", "没有符合", ]; for marker in EMPTY_TEXT { if trimmed.contains(marker) { return true; } } false } #[cfg(test)] mod tests { use super::*; #[test] fn test_empty_tool_result() { assert!(is_empty_tool_result("")); assert!(is_empty_tool_result(" ")); assert!(is_empty_tool_result(r#"{"total":0}"#)); assert!(is_empty_tool_result(r#"{"entries":[]}"#)); assert!(is_empty_tool_result("未找到相关文件")); assert!(!is_empty_tool_result(r#"{"total":5}"#)); assert!(!is_empty_tool_result(r#"{"entries":["a.txt"]}"#)); } }