重构: 后端编排拆解与DRY债收敛及命名纠偏

阶段1拆解: stream 系外移 stream_relay.rs,mod.rs 3048→2607,loop_body 502→83
技能收尾: regenerate/edit 回溯补技能,新增 read_skill 工具,项目级技能扫描
DRY: emit 双写 46 处收敛 event_bus 正典,KV 引号归一收口,LOCK-SLOW 33 处单行化
警示骨架 4→1 与压缩兜底 2→1,噪音目录档位收敛,truncate 同名陷阱消除
工具元数据落 ToolMeta 八维,7 张 match 表改查 registry,parity 测试锁等价
命名: is_noise_entry/teardown_round/probe_missed_terminal_event 等 6 项纠偏
This commit is contained in:
lxy
2026-10-01 19:35:19 +08:00
parent 3861e13b32
commit 75c5e23523
40 changed files with 2988 additions and 1964 deletions
+69 -122
View File
@@ -1,14 +1,44 @@
//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。
use tauri::{AppHandle, Emitter, Manager};
use std::sync::Arc;
use tauri::AppHandle;
use tokio::sync::Mutex;
use df_ai::provider::ChatMessage;
use crate::state::AppState;
use super::approval_timeout;
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider};
use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
use crate::commands::ai::{AiChatEvent, AiSession, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState;
/// 警示 system 消息插入单点(DRY #7 收敛,2026-10-01)。
///
/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning /
/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning):
/// 「lock → per_conv.contains_key 守卫 → messages.insert_at(0, system) → tracing::info」。
/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。
///
/// `log_tag` 为日志正文(不带 "[ai] " 前缀,helper 统一补);各站点特有字段
/// (iteration/loop_count 等)由调用方编入 tag 文本保留定位信息,helper 统一打 conv_id 字段。
pub(crate) async fn insert_system_notice(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
text: &str,
log_tag: &str,
) {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(text));
tracing::info!(conv_id = %conv_id, "[ai] {}", log_tag);
}
}
/// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
struct ContinueSnapshot {
is_generating: bool,
@@ -59,17 +89,15 @@ pub async fn try_continue_agent_loop(
Some(cid) => cid,
None => conv_id.to_string(),
};
let ev = AiChatEvent::AiCompleted {
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
emit_ai_chat_event(app, AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
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;
}
@@ -81,12 +109,11 @@ pub async fn try_continue_agent_loop(
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,
// emit/publish 原两端 error_type 不一致(emit=ProviderConfig / publish=None,
// 手抄漂移),收敛 emit_ai_chat_event 后统一 ProviderConfig(对齐 emit_fatal_error 先例)。
emit_ai_chat_event(app, AiChatEvent::AiError {
error: e,
error_type: Some(ErrorType::ProviderConfig),
conversation_id: Some(conv_id.to_string()),
});
return;
@@ -133,24 +160,19 @@ pub async fn try_continue_agent_loop(
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。
let still_generating = state.conv_states.is_active(conv_id);
if !still_generating {
let ev = AiChatEvent::AiCompleted {
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
emit_ai_chat_event(app, AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
is_estimated: false,
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 {
emit_ai_chat_event(app, AiChatEvent::AiAgentRound {
round: 0, conversation_id: Some(conv_id_owned.clone()),
});
@@ -168,63 +190,19 @@ pub async fn try_continue_agent_loop(
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。
/// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。
///
/// DRY 债 #5 收敛:动词/参数键/取值风格/缺参兜底不再本文件手写 match,
/// 改查注册元数据(goal 维度,在 declare_tool! 注册点声明)。
/// 未声明 goal 的工具不产生目标描述(与旧 match 的 _ => None 分支一致)。
/// 旧表中的幽灵分支(edit_file,注册表无此工具)随 match 删除一并清理。
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![]; }
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
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,
};
let parsed = serde_json::from_str::<serde_json::Value>(&tc.args).ok();
let goal = df_ai::ai_tools::goal_of(&tc.name)
.and_then(|g| df_ai::ai_tools::goal_text(g, parsed.as_ref()));
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
}
goals
@@ -259,56 +237,22 @@ fn arg_str(args: &serde_json::Value, key: &str) -> String {
/// 选「决定意图」参数(决定这次调用"去哪儿查什么"的参数),非决定参数(如 case_sensitive/
/// show_line_numbers/timeout 等开关/格式选项)忽略——它们变体不构成漂移。
///
/// 归一规则(参数从 args JSON 取,缺失用空串):
/// - grep/search_files/search: `pattern`(或 `query`)+ `path`(或 `glob`)
/// 归一规则(DRY 债 #5 收敛:参数组查注册元数据 sig 维度,在 declare_tool! 注册点声明):
/// - grep/search_files: `pattern`(或 `query` 回退)+ `path`(或 `glob` 回退)
/// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。
/// - read_file: `path` + `offset` + `limit`
/// - read_file: `path` + `offset` + `limit`(后两为数值键,按 JSON 形态序列化)
/// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。
/// - run_command: `command`(整条命令,含参数)。
/// - list_dir/list_directory: `path`。
/// - http_request: `url` + `method`。
/// - fetch_url: `url`。
/// - 其他/兜底: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
/// - list_directory: `path`。
/// - http_request: `url` + `method`;fetch_url: `url`。
/// - 其他/未声明: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
///
/// 返回 `"name:k1=v1,k2=v2"` 形式。输入 args 通常来自 LLM 的 tool_call function.arguments
/// (JSON 字符串),调用方先 from_str 成 Value 再传入。
/// 旧表中的幽灵分支(search/list_dir,注册表无此工具)随 match 删除一并清理。
pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String {
let pair = |k: &str, v: &str| format!("{}={}", k, v);
let sig = match name {
"grep" | "search_files" | "search" => {
let q = if args.get("pattern").and_then(|v| v.as_str()).is_some() {
arg_str(args, "pattern")
} else {
arg_str(args, "query")
};
let p = if args.get("path").and_then(|v| v.as_str()).is_some() {
arg_str(args, "path")
} else {
arg_str(args, "glob")
};
format!("{},{}", pair("pattern", &q), pair("path", &p))
}
"read_file" => {
// offset/limit 数值字段:as_str 不通,先取再转字符串(缺失→"")。
let offset = args.get("offset").map(|v| v.to_string()).unwrap_or_default();
let limit = args.get("limit").map(|v| v.to_string()).unwrap_or_default();
let path = arg_str(args, "path");
format!("{},{},{}", pair("path", &path), pair("offset", &offset), pair("limit", &limit))
}
"run_command" => pair("command", &arg_str(args, "command")),
"list_dir" | "list_directory" => pair("path", &arg_str(args, "path")),
"http_request" => {
let url = arg_str(args, "url");
let method = arg_str(args, "method");
format!("{},{}", pair("url", &url), pair("method", &method))
}
"fetch_url" => pair("url", &arg_str(args, "url")),
_ => {
// 兜底:工具名 + args 全 JSON 序列化(保守,无明确语义时全量比对)。
format!("{},{}", pair("name", name), pair("args", &args.to_string()))
}
};
format!("{}:{}", name, sig)
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
df_ai::ai_tools::tool_signature(name, args)
}
/// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。
@@ -479,9 +423,12 @@ mod tests {
}
#[test]
fn sig_list_dir_takes_path() {
fn sig_list_directory_takes_path() {
let args = serde_json::json!({"path": "/tmp"});
assert_eq!(tool_call_signature("list_dir", &args), "list_dir:path=/tmp");
assert_eq!(tool_call_signature("list_directory", &args), "list_directory:path=/tmp");
// 幽灵名 list_dir 已删分支,走未注册全参兜底
let ghost = tool_call_signature("list_dir", &args);
assert!(ghost.starts_with("list_dir:name=list_dir,args="), "list_dir 应走兜底分支: {}", ghost);
}
#[test]
@@ -531,7 +478,7 @@ mod tests {
tool_call_signature("read_file", &serde_json::json!({"path": "b.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json::json!({"path": "c.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json::json!({"path": "d.rs", "offset": 100, "limit": 50})),
tool_call_signature("list_dir", &serde_json::json!({"path": "src-tauri"})),
tool_call_signature("list_directory", &serde_json::json!({"path": "src-tauri"})),
tool_call_signature("grep", &serde_json::json!({"pattern": "STALL", "path": "src"})),
tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})),
];
@@ -562,7 +509,7 @@ mod tests {
fn scenario_repeat_same_chunk_repetitive() {
let same = tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50}));
let other1 = tool_call_signature("grep", &serde_json::json!({"pattern": "x", "path": "y"}));
let other2 = tool_call_signature("list_dir", &serde_json::json!({"path": "z"}));
let other2 = tool_call_signature("list_directory", &serde_json::json!({"path": "z"}));
let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2];
assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复");
}