新增: 工具工作流补全(diff_files工具 + 技能清单注入 + 工作流进度共享 + human端到端测试 + 知识库MCP工具)

This commit is contained in:
lxy
2026-08-08 21:24:53 +08:00
parent 6ba6daf188
commit 9eb2995a74
14 changed files with 807 additions and 93 deletions
+56
View File
@@ -151,6 +151,23 @@ pub const GOAL_MAX_CHARS: usize = 500;
/// 防无限膨胀:每次发消息提取的目标追加到 pinned_goals vec,超上限时淘汰最早目标。
pub const MAX_GOALS: usize = 5;
/// 技能清单注入开关:system_prompt 是否追加本机技能清单(名称+描述),让 LLM 感知可用技能(默认 true)。
///
/// 机制化:LLM 只读清单,用户请求命中某技能用途时能引导触发(/技能名),不做复杂执行逻辑。
/// 清单来自 skills.rs 进程内缓存(skills/commands/plugins 三类去重)。false(回退)→ 整块跳过,
/// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。
pub const SKILL_LIST_INJECT_ENABLED: bool = true;
/// 技能清单注入最大条数(默认 20)。
///
/// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。
pub const SKILL_LIST_MAX_ITEMS: usize = 20;
/// 技能清单单条描述截断长度(默认 80 字符)。
///
/// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号。
pub const SKILL_DESC_MAX_CHARS: usize = 80;
/// G4 目标感知降级:话题标记 insert 当 pinned_goals 存在时跳过(默认 true)。
///
/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的
@@ -1044,6 +1061,45 @@ pub(crate) async fn run_agentic_loop(
);
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
// 技能清单注入:把本机扫描到的技能(名称+描述)追加到 system_prompt,让 LLM 感知可用技能。
//
// 治「AI 不知道有技能可引导」:skills.rs 联想已做(前端 / 浮层),但 AI 本身对技能无感知,
// 用户需求命中某技能(如"发布flux")时 AI 不会引导。机制化——只注入清单(不执行技能逻辑,
// 执行仍由用户在 Claude Code 侧 / 技能名 触发),LLM 据此提示用户可用技能。
// 清单来自 skills_cached() 进程内缓存(懒扫描一次,后续零开销);截断条数+描述防占预算。
if SKILL_LIST_INJECT_ENABLED {
let skills = crate::commands::ai::skills::skills_cached().await;
if !skills.is_empty() {
let lines: Vec<String> = skills
.iter()
.take(SKILL_LIST_MAX_ITEMS)
.map(|s| {
let desc_flat = s.description.replace('\n', " ");
let desc: String = desc_flat.chars().take(SKILL_DESC_MAX_CHARS).collect();
let desc = if desc_flat.chars().count() > SKILL_DESC_MAX_CHARS {
format!("{}", desc)
} else {
desc
};
format!("- {}: {}", s.name, desc)
})
.collect();
if !lines.is_empty() {
system_prompt = format!(
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}",
system_prompt,
lines.join("\n")
);
tracing::debug!(
conv_id = %conv_id,
count = lines.len(),
"[ai] 技能清单注入:已追加 {} 条技能到 system_prompt",
lines.len()
);
}
}
}
// T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt
{
let session = session_arc.lock().await;
+10 -5
View File
@@ -1365,15 +1365,18 @@ mod tests {
// list_module_dependencies/add_module_dependency/remove_module_dependency,扩展 tools/task_graph.rs,
// 复用 module.rs CRUD 逻辑;delete_module 级联清理依赖边防外键约束)。
// 72 = 42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
// diff_files(2026-08-08): file 层 14→15(新增任意两文件内容对比,只读 Low,
// 补 git_diff 仅仓库内缺口;复用 generate_diff LCS unified diff;namespace ALWAYS_LARGE_TOOLS 既有引用生效)。
// 73 = 42 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
assert_eq!(
registry.len(),
72,
"工具总数应为 72(42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len()
73,
"工具总数应为 73(42 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len()
);
// 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 42 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + 工程模块写工具
// file 层 14 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载
// file 层 15 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载/对比
// http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![
// ── data 层 (42) ──
@@ -1400,16 +1403,18 @@ mod tests {
"git_status", "git_diff", "git_log",
// Git 写工具
"git_commit", "git_branch", "git_merge",
// ── file 层 (14) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
// ── file 层 (15) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能;
// download_file 新增跨平台 URL→文件流式下载)
// download_file 新增跨平台 URL→文件流式下载;diff_files 新增任意两文件对比
"read_file", "read_symbol", "list_directory", "write_file",
"patch_file", "file_info", "append_file",
"delete_file", "rename_file", "search_files", "run_command",
"grep", "detect_environment",
// download_file(URL→文件流式下载,写文件需 allowed_dirs,注册在 register_file_tools)
"download_file",
// diff_files(任意两文件内容对比,只读 Low,补 git_diff 仅仓库内缺口)
"diff_files",
// ── http 层 (1) ──
"http_request",
// ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工)
+72
View File
@@ -259,6 +259,46 @@ pub fn register(
}
);
// ── diff_files (Low 只读, 任意两文件对比) ──
// 补 git_diff 缺口:git_diff 只比仓库内版本,本工具对比任意两文件(绝对路径),
// 复用 generate_diff(LCS unified diff)生成差异,只读无副作用。
declare_tool!(
registry,
allowed_dirs: Arc<RwLock<AllowedDirs>>,
"diff_files",
"对比任意两个文件的差异,返回 unified diff(+/- 行前缀,基于 LCS)。参数 path_a/path_b 为两文件绝对路径,均须在授权目录内。只读无副作用,用于两文件/两版本内容对比(git_diff 仅仓库内,本工具补任意两文件缺口)",
RiskLevel::Low,
schema: object_schema(vec![("path_a", "string", true), ("path_b", "string", true)]),
args => {
let snap = allowed_dirs.read().await.clone();
let a_resolved = resolve_workspace_path_with_allowed(
args["path_a"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path_a 参数"))?,
&snap,
)?;
let b_resolved = resolve_workspace_path_with_allowed(
args["path_b"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path_b 参数"))?,
&snap,
)?;
let a_path = a_resolved.to_str().ok_or_else(|| anyhow::anyhow!("path_a 含非法字符"))?.to_string();
let b_path = b_resolved.to_str().ok_or_else(|| anyhow::anyhow!("path_b 含非法字符"))?.to_string();
if a_path == b_path {
anyhow::bail!("两个路径相同,无需对比: {}", a_path);
}
// 读取两文件文本(1MB 上限 + 二进制拦截,解码对齐 read_file 的 UTF-16 BOM 优先)
let (content_a, size_a) = read_text_file(&a_path).await?;
let (content_b, size_b) = read_text_file(&b_path).await?;
let diff = generate_diff(&content_a, &content_b);
Ok(serde_json::json!({
"path_a": a_path,
"path_b": b_path,
"size_a": size_a,
"size_b": size_b,
"identical": content_a == content_b,
"diff": diff,
}))
}
);
// ── list_directory (Low 只读) ──
declare_tool!(
registry,
@@ -1232,6 +1272,38 @@ pub fn register(
);
}
// ============================================================
// diff_files 辅助 — 读取文本文件内容(两文件对比共用)
// ============================================================
/// 读取文本文件内容,返回 (内容, 字节数)。
///
/// diff_files 两文件对比共用:1MB 上限 + 二进制拦截 + UTF-16 BOM 优先解码
/// (复用 decode_bytes_to_string,对齐 read_file/patch_file 的读取语义,单真相源)。
async fn read_text_file(path: &str) -> anyhow::Result<(String, u64)> {
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let mut file = File::open(path).await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!("文件不存在: {}", path)
} else {
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
}
})?;
let metadata = file.metadata().await
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
if metadata.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
}
let mut raw_bytes = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut raw_bytes).await
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
let content = decode_bytes_to_string(&raw_bytes)
.map_err(|_| anyhow::anyhow!("文件非 UTF-8 文本(疑似二进制),无法对比: {}", path))?;
Ok((content, metadata.len()))
}
// ============================================================
// patch_file 失败提示辅助 — old_text 不匹配时给 LLM 相近锚点
// ============================================================