- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条) - queue 加 conversationId 字段,按会话精准 drain - regenerate/editMessage 只清本会话排队消息 - newConversation 保留旧会话排队消息 - AiError 只清出错会话的队列项
233 lines
10 KiB
Rust
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"]}"#));
|
|
}
|
|
}
|