//! 文件系统类 AI 工具声明式注册(register_file_tools 13 个迁入)。 //! //! 迁自 `tool_registry.rs::register_file_tools`(原 13 个 read_file/read_symbol/ //! list_directory/write_file/patch_file/file_info/append_file/delete_file/rename_file/ //! grep/search_files/run_command/detect_environment),改用 `declare_tool!` 宏。 //! //! 迁移策略(handler 逻辑零变更): //! - handler body 逐字照搬原 `register_file_tools` 内 async move 块(逻辑等价), //! 仅闭包包装(`{ let allowed_dirs = ...; Box::new(move |args| { ... Box::pin(async move {...}) }) }`) //! 改由 `declare_tool!` 宏生成。 //! - name/desc/schema/risk 与原手写定义逐字一致。 //! - 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有 fn/const 改 pub(crate)): //! DEFAULT_RUN_COMMAND_TIMEOUT_SECS / MAX_RUN_COMMAND_TIMEOUT_SECS / truncate_output / //! validate_path / generate_diff / apply_line_range / resolve_anchor_to_lines / //! resolve_workspace_path_with_allowed / rename_or_cross_volume_copy / compute_file_hash / //! FILE_LOCKS / probe_executable / list_dir_recursive / compile_glob_to_regex / //! grep_recursive / grep_one_file / search_files_recursive / is_noise_dir / is_noise_file / //! FileGrepHit / LineMatch。 //! //! 单捕获宏适配(declare_tool! 宏仅支持单 capture 变量): //! - 文件工具主体捕获 `allowed_dirs: Arc>`。 //! - delete_file 额外捕获 data_dir:PathBuf,用打包 capture //! `delete_ctx: Arc<(Arc>, PathBuf)>`,handler 首行解包为 //! `let allowed_dirs = &delete_ctx.0; let data_dir = &delete_ctx.1;`,其余 body 逐字不变。 //! - run_command/detect_environment 零捕获(原 `Box::new(|args| ...)` 不含 allowed_dirs), //! 用 `Arc<()>` 占位 capture(handler body 不引用它),对齐 declare_tool_no_capture 测试模式。 //! //! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + //! 工具名集合稳定(防 rename / 漏注册)。 use std::sync::Arc; use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; use df_ai::declare_tool; use tokio::sync::RwLock; use crate::state::AllowedDirs; // 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有项已改 pub(crate)) use crate::commands::ai::tool_registry::{ apply_line_range, compile_glob_to_regex, compute_file_hash, generate_diff, grep_one_file, grep_recursive, list_dir_recursive, probe_executable, rename_or_cross_volume_copy, resolve_anchor_to_lines, resolve_workspace_path_with_allowed, search_files_recursive, truncate_output, validate_path, DEFAULT_RUN_COMMAND_TIMEOUT_SECS, MAX_RUN_COMMAND_TIMEOUT_SECS, FILE_LOCKS, FileGrepHit, }; // run_command 依赖 df_execute::shell::{execute, ShellRequest} + std HashMap // new_id: delete_file 软删除备份命名(原 register_file_tools 闭包引用,经 df_types::types::new_id) use df_execute::shell::{execute, ShellRequest}; use df_types::types::new_id; use std::collections::HashMap; /// 注册 13 个文件系统类工具到 `$registry`。 /// /// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: /// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 /// - risk 与原一致(read/symbol/list/file_info/search/grep/detect_environment=Low, /// write/patch/append/rename=Medium,delete/run_command=High) /// - handler body 与原 async move 块逐字一致(逻辑零变更,路径授权/risk/diff/patch 全保留) /// /// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 pub fn register( registry: &mut AiToolRegistry, allowed_dirs: &Arc>, data_dir: std::path::PathBuf, ) { // ── read_file (Low 只读) ── declare_tool!( registry, allowed_dirs: Arc>, "read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)", RiskLevel::Low, schema: object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]), args => { let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed( args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, &snap, )?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; // 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限) 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!( "无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表", 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()); } // TD-260621-04 闭环:返回 file_hash 供 patch_file.expected_hash 比对(防并发修改)。 // 三返回点(二进制降级/search/默认分页)共用此值,文件未改时跨分页稳定。 let file_hash = compute_file_hash(&metadata); // 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话) let mut content = String::new(); if let Err(e) = file.read_to_string(&mut content).await { if e.kind() == std::io::ErrorKind::InvalidData { return Ok(serde_json::json!({ "path": path, "content": null, "binary": true, "size": metadata.len(), "file_hash": file_hash, "error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取" })); } anyhow::bail!("读取文件失败: {}", e); } // search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页 if let Some(search) = args["search"].as_str() { const SEARCH_MAX: usize = 50; let search_offset = args["offset"].as_u64().unwrap_or(0) as usize; let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize; // 先收集全部匹配行用于 total 计数,再做 skip/take 分页 let mut all_matches: Vec = Vec::new(); for (idx, line) in content.lines().enumerate() { if line.contains(search) { all_matches.push(serde_json::json!({ "line": idx + 1, "content": line })); } } let total = all_matches.len(); let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect(); let has_more = (search_offset + page_matches.len()) < total; return Ok(serde_json::json!({ "path": path, "size": metadata.len(), "file_hash": file_hash, "search": search, "matches": page_matches, "total": total, "has_more": has_more, })); } // 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行) // BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件, // 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。 // 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。 let line_count = content.lines().count(); let (result, offset_used, has_more) = if let Some(offset) = args["offset"].as_u64() { let lines: Vec<&str> = content.lines().collect(); let skip = offset as usize; let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize; let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect(); let more = (skip + page.len()) < line_count; (page.join("\n"), Some(skip), more) } else { // 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义), // 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。 // BUG-260623-01: 旧实现固定 take(500) 忽略 limit, // 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。 const DEFAULT_PREVIEW_LINES: usize = 500; let limit = args["limit"].as_u64() .unwrap_or(DEFAULT_PREVIEW_LINES as u64) .min(2000) as usize; let page: Vec<&str> = content.lines().take(limit).collect(); let more = line_count > page.len(); (page.join("\n"), None, more) }; Ok(serde_json::json!({ "path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count, "offset": offset_used, "returned_lines": result.lines().count(), "has_more": has_more, })) } ); // ── read_symbol (Low 只读, AST 符号解析) ── declare_tool!( registry, allowed_dirs: Arc>, "read_symbol", "AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。", RiskLevel::Low, schema: object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false), ("drill", "string", false)]), args => { let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed( args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, &snap, )?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let symbol = args["symbol"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 symbol 参数"))?; let full = args["full"].as_bool().unwrap_or(false); let kind_hint = args["kind"].as_str(); let drill = args["drill"].as_str().filter(|s| !s.is_empty()); use tokio::fs::File; use tokio::io::AsyncReadExt; let mut file = File::open(path).await .map_err(|e| 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 file_hash = compute_file_hash(&metadata); let mut content = String::new(); if let Err(e) = file.read_to_string(&mut content).await { if e.kind() == std::io::ErrorKind::InvalidData { return Ok(serde_json::json!({ "path": path, "binary": true, "size": metadata.len(), "file_hash": file_hash, "fallback": true, "reason": "binary", "suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容", })); } anyhow::bail!("读取文件失败: {}", e); } // 调 code_intel 纯函数(三态 + 兜底,不 panic) Ok(crate::commands::ai::code_intel::read_symbol( &content, &file_hash, path, symbol, full, kind_hint, drill, )) } ); // ── list_directory (Low 只读) ── declare_tool!( registry, allowed_dirs: Arc>, "list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示", RiskLevel::Low, schema: object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]), args => { let p = args["path"].as_str().unwrap_or(""); if p.is_empty() { return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"})); } let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed(p, &snap)?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let recursive = args["recursive"].as_bool().unwrap_or(false); let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); // BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000) // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。 let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize; let mut entries = Vec::new(); let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?; Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated })) } ); // ── write_file (Medium 写) ── declare_tool!( registry, allowed_dirs: Arc>, "write_file", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本", RiskLevel::Medium, schema: object_schema(vec![ ("path", "string", true), ("content", "string", true), ("encoding", "string", false), ]), args => { let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed( args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, &snap, )?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; // F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等) // 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec 写字节 let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8"); let bytes: Vec = if encoding.eq_ignore_ascii_case("base64") { use base64::{engine::general_purpose::STANDARD, Engine as _}; STANDARD.decode(raw) .map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))? } else if encoding.eq_ignore_ascii_case("utf-8") { raw.as_bytes().to_vec() } else { anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64)", encoding); }; // 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史) if bytes.len() > 1_048_576 { anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len()); } let target = std::path::Path::new(path); // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失) // 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节 let old_size: Option = match tokio::fs::metadata(target).await { Ok(m) if m.len() > 0 => { let bak = format!("{}.bak", path); tokio::fs::copy(path, &bak).await .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?; Some(m.len()) } Ok(_) => Some(0), // 空文件(无需备份) Err(_) => None, // 不存在(新建) }; if let Some(parent) = target.parent() { // FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。 // BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目 // 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。 if !snap.is_authorized(parent) { anyhow::bail!("禁止在项目目录之外创建目录"); } tokio::fs::create_dir_all(parent).await .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; } // FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷) let tmp = format!("{}.tmp-write", path); if let Err(e) = tokio::fs::write(&tmp, &bytes).await { let _ = tokio::fs::remove_file(&tmp).await; return Err(anyhow::anyhow!("写入临时文件失败: {}", e)); } if let Err(e) = tokio::fs::rename(&tmp, path).await { let _ = tokio::fs::remove_file(&tmp).await; return Err(anyhow::anyhow!("原子替换失败: {}", e)); } // R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要); // rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。 // 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件) if old_size.map(|s| s > 0).unwrap_or(false) { let bak = format!("{}.bak", path); let _ = tokio::fs::remove_file(&bak).await; } // FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak if let Some(old) = old_size { if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 { tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len()); } } Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding })) } ); // ── patch_file (Medium, 三模式互斥) ── // object_schema 只支持扁平标量三元组,故 replace_lines/anchor 嵌套对象手工拼 schema。 let patch_file_schema = { let mut props = serde_json::Map::new(); props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" })); props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" })); props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" })); props.insert("replace_lines".into(), serde_json::json!({ "type": "object", "description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移", "properties": { "start": { "type": "integer", "description": "起始行号(1-based,含)" }, "end": { "type": "integer", "description": "结束行号(1-based,含)" } }, "required": ["start", "end"] })); props.insert("anchor".into(), serde_json::json!({ "type": "object", "description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文", "properties": { "start": { "type": "string", "description": "首行子串标记(大小写敏感)" }, "end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" } }, "required": ["start", "end"] })); props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "(old_text 模式)行号辅助定位,可选" })); props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹(read_file 返回的 file_hash),三模式通用,防并发修改" })); serde_json::json!({ "type": "object", "properties": props, "required": ["path", "new_text"], }) }; declare_tool!( registry, allowed_dirs: Arc>, "patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。", RiskLevel::Medium, schema: patch_file_schema, args => { let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed( args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, &snap, )?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let new_text = args["new_text"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?; // F-260617-01: 三模式互斥校验(old_text / replace_lines / anchor) // 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式) let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false); let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false); let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false); let mode_count = [has_old_text, has_replace_lines, has_anchor].iter().filter(|&&b| b).count(); if mode_count == 0 { anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一"); } if mode_count > 1 { anyhow::bail!( "模式互斥冲突:old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)", mode_count ); } // 文件存在性 / 大小限制(三模式通用,先于内容读取) let target = std::path::Path::new(path); if !target.exists() { anyhow::bail!("文件不存在: {}", path); } let file_meta = tokio::fs::metadata(target).await .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; if file_meta.len() > 1_048_576 { anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len()); } // TD-260621-03:读改写整体锁内防 lost update。 // 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件: // A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。 // 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。 // 顺带修 entry().or_insert(()) 内存泄漏(FILE_LOCKS HashMap 只增不清,P2 精选项)。 let _patch_guard = FILE_LOCKS.lock().await; // 读文件内容 + 校验(锁内,纯读 + CPU 计算) use tokio::io::AsyncReadExt; let mut file = tokio::fs::File::open(path).await .map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?; let mut content = String::new(); file.read_to_string(&mut content).await .map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?; // 二进制检测 if content.contains('\0') { anyhow::bail!("不支持二进制文件"); } // L3: expected_hash 指纹校验(防外部修改,TD-260621-04 闭环:与 read_file 返回的 file_hash 同格式) if let Some(expected) = args["expected_hash"].as_str() { let current_hash = compute_file_hash(&file_meta); if current_hash != expected { anyhow::bail!( "文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容", expected, current_hash ); } } // F-260617-01: 三模式分派计算 new_content + match_count + warning // old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处) // replace_lines 模式:行号区间 splice(越界 Err) // anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err) let new_content: String; let match_count: usize; let warning: Option; if has_old_text { let old_text = args["old_text"].as_str() .ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?; if old_text.is_empty() { anyhow::bail!("old_text 不能为空"); } if old_text == new_text { return Ok(serde_json::json!({ "path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改" })); } // L2: old_text 精确匹配(CAS 语义) if !content.contains(old_text) { anyhow::bail!("未找到目标文本,文件可能已被修改"); } let mc = content.matches(old_text).count(); match_count = mc; warning = if mc > 1 { Some(format!("匹配到 {} 处,仅替换第 1 处", mc)) } else { None }; new_content = content.replacen(old_text, new_text, 1); } else if has_replace_lines { let rl = args.get("replace_lines") .ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?; let start = rl["start"].as_u64() .ok_or_else(|| anyhow::anyhow!("replace_lines.start 必须为正整数"))? as usize; let end = rl["end"].as_u64() .ok_or_else(|| anyhow::anyhow!("replace_lines.end 必须为正整数"))? as usize; new_content = apply_line_range(&content, start, end, new_text)?; match_count = 1; warning = None; } else { // has_anchor let an = args.get("anchor") .ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?; let a_start = an["start"].as_str() .ok_or_else(|| anyhow::anyhow!("anchor.start 必须为字符串"))?; let a_end = an["end"].as_str() .ok_or_else(|| anyhow::anyhow!("anchor.end 必须为字符串"))?; let (start_line, end_line) = resolve_anchor_to_lines(&content, a_start, a_end)?; new_content = apply_line_range(&content, start_line, end_line, new_text)?; match_count = 1; warning = None; } // 写序列(_patch_guard 持锁中:backup → tmp write → rename → cleanup) // .bak 备份 let bak = format!("{}.bak", path); tokio::fs::copy(path, &bak).await .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?; // 原子写: tmp → rename let tmp = format!("{}.tmp-write", path); if let Err(e) = tokio::fs::write(&tmp, &new_content).await { let _ = tokio::fs::remove_file(&tmp).await; let _ = tokio::fs::remove_file(&bak).await; return Err(anyhow::anyhow!("写入临时文件失败: {}", e)); } if let Err(e) = tokio::fs::rename(&tmp, path).await { let _ = tokio::fs::remove_file(&tmp).await; return Err(anyhow::anyhow!("原子替换失败: {},备份保留在 {}", e, bak)); } // 成功:清理 .bak let _ = tokio::fs::remove_file(&bak).await; drop(_patch_guard); // 释放锁(diff 计算纯 CPU,不需持锁) let size_diff = new_content.len() as i64 - content.len() as i64; // 生成 unified diff 供前端审批卡/审计留痕展示 // content(旧)与 new_content(新)作用域内可用,无需重读文件 let diff = generate_diff(&content, &new_content); let mut result = serde_json::json!({ "path": path, "changed": true, "size_diff": size_diff, "matches_found": match_count, "diff": diff, }); if let Some(w) = warning { result["warning"] = serde_json::Value::String(w); } Ok(result) } ); // ── file_info (Low 只读) ── declare_tool!( registry, allowed_dirs: Arc>, "file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容", RiskLevel::Low, schema: object_schema(vec![("path", "string", true)]), args => { let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed( args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, &snap, )?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let p = std::path::Path::new(path); if !p.exists() { return Ok(serde_json::json!({ "path": path, "exists": false })); } let metadata = tokio::fs::metadata(path).await .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?; let is_dir = metadata.is_dir(); let size = metadata.len(); let modified = metadata.modified() .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as i64); // is_binary: 流式读前 8KB 检测 \x00 (BUG-260617-02) // 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192, // >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。 // 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测, // size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。 let is_binary = if !is_dir && size > 0 { use tokio::io::AsyncReadExt; let file = match tokio::fs::File::open(path).await { Ok(f) => f, Err(_) => return Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": serde_json::Value::Null, "modified": modified, "is_binary": false, "is_dir": is_dir, "error": "读取文件头失败" })), }; let mut reader = tokio::io::BufReader::new(file); let mut sample = vec![0u8; 8192]; let n = reader.read(&mut sample).await.unwrap_or(0); sample[..n].contains(&0x00) } else { false }; // lines: 文本文件 \n 计数(>2MB 跳过避免全量读) let lines = if !is_dir && !is_binary && size <= 2_097_152 { tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64) } else { None }; Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir })) } ); // ── append_file (Medium 追加) ── declare_tool!( registry, allowed_dirs: Arc>, "append_file", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小", RiskLevel::Medium, schema: object_schema(vec![("path", "string", true), ("content", "string", true)]), args => { let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed( args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, &snap, )?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; if let Some(parent) = std::path::Path::new(path).parent() { // BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 if !snap.is_authorized(parent) { anyhow::bail!("禁止在项目目录之外创建目录"); } tokio::fs::create_dir_all(parent).await .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; } use tokio::io::AsyncWriteExt; let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await .map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?; let bytes = content.as_bytes(); file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?; file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?; let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0); Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size })) } ); // ── delete_file (High, 双捕获 allowed_dirs + data_dir) ── // declare_tool! 宏仅支持单 capture。delete_file 同时需 allowed_dirs + data_dir, // 故打包为 delete_ctx: Arc<(Arc>, PathBuf)> 单 capture, // handler 首行解包为 (allowed_dirs, data_dir),其余 body 逐字照搬(逻辑零变更)。 let delete_ctx = Arc::new((allowed_dirs.clone(), data_dir.clone())); declare_tool!( registry, delete_ctx: Arc<(Arc>, std::path::PathBuf)>, "delete_file", "删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准", RiskLevel::High, schema: object_schema(vec![ ("path", "string", true), ("permanent", "boolean", false), ]), args => { // 单 capture 宏适配:解包 delete_ctx 取 (allowed_dirs, data_dir),其余 body 逐字照搬 let allowed_dirs = &delete_ctx.0; let data_dir = &delete_ctx.1; // validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸 let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed( args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, &snap, )?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false); let target = std::path::Path::new(path); if !target.exists() { anyhow::bail!("文件不存在: {}", path); } // 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底) let metadata = tokio::fs::metadata(target).await .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?; if metadata.is_dir() { anyhow::bail!("delete_file 仅支持删除文件,不支持目录: {}", path); } let file_name = target.file_name() .ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))? .to_string_lossy().to_string(); if permanent { // 硬删除:不可逆,对齐 delete_project purge 语义 tokio::fs::remove_file(path).await .map_err(|e| anyhow::anyhow!("删除失败: {}", e))?; Ok(serde_json::json!({ "path": path, "deleted": true, "permanent": true, "backed_up": false, })) } else { // 软删除:移到 .trash/-,可恢复(对齐 list_trash 机制) // .trash 放 app_data_dir 下,不依赖编译期 workspace_root let trash_dir = data_dir.join(".trash"); tokio::fs::create_dir_all(&trash_dir).await .map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?; let backup_name = format!("{}-{}", new_id(), file_name); let backup_path = trash_dir.join(&backup_name); let backup_path_str = backup_path.to_string_lossy().to_string(); // 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17, // 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子, // 失败回滚删 backup 保源完整)。 // 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理), // 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败 // (用户授权工程外 C 盘路径删除时,前几个卡"执行中..."+ 末个报 os error 17)。 // .trash 固定在 workspace_root,授权工程外路径删除必然跨盘。 // helper 内部错误文案含"跨卷复制失败/remove源失败"等,此处 map_err 转成"移入回收站"语义。 if let Err(e) = rename_or_cross_volume_copy(path, &backup_path_str).await { anyhow::bail!("移入回收站失败({})", e); } Ok(serde_json::json!({ "path": path, "deleted": true, "permanent": false, "backed_up": true, "backup_path": backup_path_str, })) } } ); // ── rename_file (Medium 重命名/移动) ── // 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码 // 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。 declare_tool!( registry, allowed_dirs: Arc>, "rename_file", "重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准", RiskLevel::Medium, schema: object_schema(vec![ ("from", "string", true), ("to", "string", true), ("overwrite", "boolean", false), ]), args => { // 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path_with_allowed, // 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸 let snap = allowed_dirs.read().await.clone(); let from_resolved = resolve_workspace_path_with_allowed( args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?, &snap, )?; let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?; let to_resolved = resolve_workspace_path_with_allowed( args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?, &snap, )?; let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?; let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false); // 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底) let from_target = std::path::Path::new(from_path); if !from_target.exists() { anyhow::bail!("源文件不存在: {}", from_path); } let from_metadata = tokio::fs::metadata(from_target).await .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?; if from_metadata.is_dir() { anyhow::bail!("rename_file 仅支持文件,不支持目录: {}", from_path); } let bytes_moved = from_metadata.len(); // 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败) let to_target = std::path::Path::new(to_path); if let Some(parent) = to_target.parent() { // BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行 if !snap.is_authorized(parent) { anyhow::bail!("禁止在项目目录之外创建目录"); } tokio::fs::create_dir_all(parent).await .map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?; } // 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to if to_target.exists() { if !overwrite { anyhow::bail!("目标已存在且 overwrite=false: {}", to_path); } tokio::fs::remove_file(to_path).await .map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?; } // 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题) // 阶段4:跨卷降级抽统一 helper rename_or_cross_volume_copy(对齐 delete_file .trash 跨盘降级), // 消除两处 copy+remove 字面量重复(原 inline 逻辑与此 helper 等价,行为零变更)。 let cross_volume = rename_or_cross_volume_copy(from_path, to_path).await?; Ok(serde_json::json!({ "from": from_path, "to": to_path, "renamed": true, "bytes_moved": bytes_moved, "cross_volume": cross_volume, })) } ); // ── grep (Low 跨文件内容搜索) ── // 缺口补齐:search_files 只搜文件名、read_file 只搜单文件内容,grep 提供 grep -rn 跨文件内容搜索。 let grep_schema = { let mut props = serde_json::Map::new(); props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" })); props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。不传时引导用户指定" })); props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" })); props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] })); props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" })); props.insert("-i".into(), serde_json::json!({ "type": "boolean", "description": "大小写不敏感(默认 false,大小写敏感)" })); props.insert("-C".into(), serde_json::json!({ "type": "integer", "description": "上下文行数(content 模式,命中行前后各 N 行,默认 0)", "minimum": 0, "maximum": 10 })); props.insert("max_results".into(), serde_json::json!({ "type": "integer", "description": "返回上限(防撑爆 context,默认 50)", "minimum": 1, "maximum": 200 })); serde_json::json!({ "type": "object", "properties": props, "required": ["pattern"], }) }; declare_tool!( registry, allowed_dirs: Arc>, "grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)", RiskLevel::Low, schema: grep_schema, args => { let p = args["path"].as_str().unwrap_or(""); if p.is_empty() { return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"})); } let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed(p, &snap)?; let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let pattern = args["pattern"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; if pattern.is_empty() { anyhow::bail!("pattern 不能为空"); } let glob_opt = args.get("glob").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); let case_insensitive = args.get("-i").and_then(|v| v.as_bool()).unwrap_or(false); let show_line = args.get("-n").and_then(|v| v.as_bool()).unwrap_or(true); let context_lines = args.get("-C").and_then(|v| v.as_u64()).unwrap_or(0).min(10) as usize; let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content"); let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize; // 编译正则:case_insensitive 开 i flag;失败尝试自动修复常见问题(未分组 | 用 (?:...) 包裹) let mut re_builder = regex::RegexBuilder::new(pattern); re_builder.case_insensitive(case_insensitive); let (re, _auto_fixed) = match re_builder.build() { Ok(r) => (r, false), Err(e) => { // LLM 常传 `a|b|c` 未分组模式(如 ` (r, true), Err(_) => anyhow::bail!( "正则编译失败「{}」: {}。提示:多选模式请用 (?:a|b|c) 分组", pattern, e ), } } }; // glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。 // 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。 let glob_re = match glob_opt { Some(g) => Some(compile_glob_to_regex(g) .map_err(|e| anyhow::anyhow!("glob 编译失败「{}」: {}", g, e))?), None => None, }; // 递归遍历+逐文件读+行匹配,收集结果 let mut matches_out: Vec = Vec::new(); let mut total: usize = 0; let mut truncated = false; // BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。 // 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。 // 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。 let root_meta = tokio::fs::metadata(root).await .map_err(|e| anyhow::anyhow!("无法访问路径 {}: {}", root, e))?; if root_meta.is_file() { let file_name = std::path::Path::new(root) .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); grep_one_file( std::path::Path::new(root), &file_name, &re, glob_re.as_ref(), output_mode, context_lines, max_results, &mut matches_out, &mut total, &mut truncated, ).await?; } else { grep_recursive( root, &re, glob_re.as_ref(), output_mode, context_lines, max_results, 0, 6, &mut matches_out, &mut total, &mut truncated, ).await?; } // output_mode 分派返回结构 let result = match output_mode { "files_with_matches" => { // 仅返命中文件路径列表(去重,顺序保留首次命中) let files: Vec = matches_out.iter() .map(|h| h.file.clone()) .collect(); serde_json::json!({ "path": root, "pattern": pattern, "output_mode": output_mode, "files": files, "total": files.len(), "truncated": truncated, }) } "count" => { // 每文件命中行数 let counts: Vec = matches_out.iter() .map(|h| serde_json::json!({ "file": h.file, "count": h.line_matches.len() })) .collect(); let total_files = counts.len(); serde_json::json!({ "path": root, "pattern": pattern, "output_mode": output_mode, "counts": counts, "total": total, "total_files": total_files, "truncated": truncated, }) } _ => { // content 模式(默认):展平所有命中行为 matches[{file,line,content,context?}] let mut lines: Vec = Vec::new(); for hit in &matches_out { for lm in &hit.line_matches { let line_no = if show_line { serde_json::Value::from(lm.line) } else { serde_json::Value::Null }; let mut entry = serde_json::json!({ "file": hit.file, "line": line_no, "content": lm.content, }); if context_lines > 0 && !lm.context.is_empty() { entry["context"] = serde_json::Value::String(lm.context.clone()); } lines.push(entry); } } serde_json::json!({ "path": root, "pattern": pattern, "output_mode": "content", "matches": lines, "total": total, "truncated": truncated, }) } }; Ok(result) } ); // ── search_files (Low 文件名搜索) ── declare_tool!( registry, allowed_dirs: Arc>, "search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50", RiskLevel::Low, schema: object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]), args => { let p = args["path"].as_str().unwrap_or(""); if p.is_empty() { return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"})); } let snap = allowed_dirs.read().await.clone(); let resolved = resolve_workspace_path_with_allowed(p, &snap)?; let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; let recursive = args["recursive"].as_bool().unwrap_or(false); let pattern_lower = pattern.to_lowercase(); const MAX_RESULTS: usize = 50; let offset = args["offset"].as_u64().unwrap_or(0) as usize; let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize; // 先收集全部结果用于 total 计数,再做 skip/take 分页 let mut all_results = Vec::new(); let mut total = 0u64; // 用较大上限收集全量(分页由内存 skip/take 控制) search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?; let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect(); let has_more = (offset + page_results.len()) < total as usize; Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more })) } ); // ── run_command (High 零捕获, Arc<()> 占位 capture) ── // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs(放开目录让 AI 在用户任意项目目录闭环)。 // declare_tool! 宏需 capture 形参,用 Arc<()> 占位(handler body 不引用它)。 let dummy: Arc<()> = Arc::new(()); declare_tool!( registry, dummy: Arc<()>, "run_command", "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。", RiskLevel::High, schema: object_schema(vec![ ("command", "string", true), ("working_dir", "string", false), ("timeout_secs", "integer", false), ]), args => { // BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致 // JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str() // 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸 // 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。 let command = args["command"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))? .to_string(); // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。 // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。 let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { Some(d) => { validate_path(d)?; d.to_string() } None => String::new(), }; // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖 // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等) let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS); let request = ShellRequest { command: command.clone(), // 空字符串 → None(空路径是非法 current_dir,Windows 报 os error 123) working_dir: if working_dir.is_empty() { None } else { Some(working_dir.clone()) }, env: HashMap::new(), timeout_secs: Some(timeout_secs), shell_type: Default::default(), }; // F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。 // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string() // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 → // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。 // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注, // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。 // // 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`), // LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、 // 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。 let shell_hint = if cfg!(windows) { "\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\" 或 \"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)" } else { "" }; let result = execute(request).await.map_err(|e| { let msg = e.to_string(); if msg.contains("命令执行超时") { anyhow::anyhow!( "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}", timeout_secs, shell_hint ) } else { anyhow::anyhow!("{}{}", msg, shell_hint) } })?; // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾) const MAX_OUT: usize = 10_000; let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT); let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT); Ok(serde_json::json!({ "command": command, "working_dir": working_dir, "exit_code": result.exit_code, "duration_ms": result.duration_ms, "stdout": stdout, "stderr": stderr, "truncated": stdout_trunc || stderr_trunc, })) } ); // ── detect_environment (Low 零捕获, Arc<()> 占位 capture, L1 agent 元能力层) ── // 返回 JSON:{ os, default_shell, python_path, node_path }。 let dummy_env: Arc<()> = Arc::new(()); declare_tool!( registry, dummy_env: Arc<()>, "detect_environment", "探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)", RiskLevel::Low, schema: object_schema(vec![]), args => { // 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown) let os = if cfg!(target_os = "windows") { "Windows" } else if cfg!(target_os = "macos") { "macOS" } else if cfg!(target_os = "linux") { "Linux" } else { "Unknown" }; // 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。 // 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。 // 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。 let default_shell = if cfg!(target_os = "windows") { "PowerShell" } else { "bash" }; // 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测), // 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。 let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") { Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"), Err(_) => true, // 未设 = 默认开 }; let mut errors: Vec = Vec::new(); let (python_path, node_path) = if probe_enabled { // Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。 // probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。 // Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。 let py = if cfg!(target_os = "windows") { probe_executable("python", "where python", &mut errors).await } else { let p3 = probe_executable("python3", "which python3", &mut errors).await; if p3.is_some() { p3 } else { // python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。 probe_executable("python", "which python", &mut errors).await } }; let node = if cfg!(target_os = "windows") { probe_executable("node", "where node", &mut errors).await } else { probe_executable("node", "which node", &mut errors).await }; (py, node) } else { // 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。 (None, None) }; Ok(serde_json::json!({ "os": os, "default_shell": default_shell, "python_path": python_path, "node_path": node_path, "probe_enabled": probe_enabled, "errors": errors, })) } ); } // probe_executable 仍在 super::tool_registry(detect_environment 原引用),迁移后该私有 fn // 仅 file 层 detect_environment handler 引用 → 改 pub(crate),本模块 use 引入(见上方 import)。