Files
DevFlow/src-tauri/src/commands/ai/agentic/helpers.rs
绝尘 e9e3578d26 修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离
- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条)
- queue 加 conversationId 字段,按会话精准 drain
- regenerate/editMessage 只清本会话排队消息
- newConversation 保留旧会话排队消息
- AiError 只清出错会话的队列项
2026-07-20 00:19:50 +08:00

233 lines
10 KiB
Rust

//! 审批后循环恢复 — 从 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<String>,
agent_language: Option<String>,
model_override: Option<String>,
pinned_goals_snapshot: Vec<GoalEntry>,
}
/// 检查是否所有待审批已处理,如果是则恢复 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::<crate::state::AppState>().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::<crate::state::AppState>().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::<crate::state::AppState>().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::<crate::state::AppState>().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<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
if tool_calls.is_empty() { return vec![]; }
let mut goals: Vec<String> = 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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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"]}"#));
}
}