优化: 狗粮短板收口与patch_file增强

- patch_file old_text 精确未中时空白归一化回退(行首缩进归一+多空白折叠,唯一命中按整行区间替换,warning 注明)
- patch_file anchor end 唯一性校验:多处命中报错并列前两处位置,治实测×2 锚点错位;三模式同传按优先级仲裁(anchor>old_text>replace_lines),warning 注明
- tool_result 压缩 memoization:PerConvState 加 tool_compress_cache(内容哈希→压缩结果),主 loop+子代理双调用点接线,治 369 组同内容反复重压
- 标题采样改两段式:user 首条必进锚定主题+其余按序补足 6 条,治 assistant 首句被当主题
- prompt 注入文件读取纪律段(父会话+子代理同源):read_file 分页/search 直读,禁 run_command 切片读文件;read_file 工具描述与 truncated 提示补大 limit 建议
- 顺带 DRY#1:agentic/mod.rs 三处裸 emit 换正典双写(心跳/破坏性回环熔断/stall 熔断),miniapp 跨端盲区补齐
This commit is contained in:
lxy
2026-10-05 00:35:04 +08:00
parent c83ea0c315
commit 6a50193b34
8 changed files with 380 additions and 79 deletions
+22 -12
View File
@@ -1668,20 +1668,24 @@ async fn prepare_round_inputs(
// 压缩决策经 per_conv.tool_view_decisions 冻结:首次判定后沿用,不再随阈值翻转
// (防同一条历史跨轮在全文/摘要间翻转致前缀断)。写回仅在 conv 存活时做(防惰性建)。
let messages: Vec<ChatMessage> = if TOOL_RESULT_COMPRESS_ENABLED {
let (history_tokens_snapshot, mut view_decisions) = {
let (history_tokens_snapshot, mut view_decisions, mut compress_cache) = {
let session = session_arc.lock().await;
let conv = session.conv_read(conv_id);
(
conv.map(|c| c.messages.history_tokens()).unwrap_or(0),
conv.map(|c| c.tool_view_decisions.clone()).unwrap_or_default(),
conv.map(|c| c.tool_compress_cache.clone()).unwrap_or_default(),
)
};
let messages =
summarize_tool_results(messages, history_tokens_snapshot, &mut view_decisions, conv_id, iteration);
if !view_decisions.is_empty() {
let messages = summarize_tool_results(
messages, history_tokens_snapshot, &mut view_decisions, &mut compress_cache, conv_id, iteration,
);
if !view_decisions.is_empty() || !compress_cache.is_empty() {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
session.conv(conv_id).tool_view_decisions = view_decisions;
let conv = session.conv(conv_id);
conv.tool_view_decisions = view_decisions;
conv.tool_compress_cache = compress_cache;
}
}
messages
@@ -2068,8 +2072,9 @@ async fn heartbeat_loop(
_ = heartbeat_rx.changed() => break,
_ = tokio::time::sleep(std::time::Duration::from_secs(20)) => {
last_heartbeat.store(df_types::now_millis() as u64, std::sync::atomic::Ordering::SeqCst);
let _ = app.emit(
"ai-chat-event",
// DRY #1:双写正典(miniapp 端靠心跳判活,只 emit 端看不到)
emit_ai_chat_event(
app,
AiChatEvent::AiHeartbeat { conversation_id: Some(conv_id.to_string()) },
);
}
@@ -2144,7 +2149,8 @@ fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count
conversation_id: Some(conv_id.to_string()),
}
};
let _ = app_handle.emit("ai-chat-event", ev);
// DRY #1(2026-10-04):裸 emit 换正典双写——原只 emit 未 publish,miniapp 端收不到熔断事件。
emit_ai_chat_event(app_handle, ev);
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
crate::commands::ai::notify::notify_session_event(
app_handle,
@@ -2277,8 +2283,9 @@ async fn check_stall_breaker(
"[ai] G2 破坏性回环熔断:连续 {} 轮检测到同路径写删回环,疑似自毁循环停止", new_loop_count
);
let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径");
let _ = app_handle.emit(
"ai-chat-event",
// DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
@@ -2307,8 +2314,9 @@ async fn check_stall_breaker(
sample_size = signatures.len(),
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
);
let _ = app_handle.emit(
"ai-chat-event",
// DRY #1:双写正典(熔断求助卡跨端可见)
emit_ai_chat_event(
app_handle,
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
@@ -2572,6 +2580,8 @@ pub(crate) async fn emit_ai_completed_once(
emit_ai_chat_event(app_handle, ev);
return;
}
// 有意不走 emit_ai_chat_event(emit/publish 两端 incomplete 标志分叉,见上注释)——
// 此分支语义就是单路 emit 不 publish,裸写是正确形态,勿"收口"。
let _ = app_handle.emit("ai-chat-event", ev);
if do_publish {
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
@@ -279,6 +279,7 @@ pub(crate) fn summarize_tool_results(
messages: Vec<ChatMessage>,
history_tokens_snapshot: u32,
view_decisions: &mut std::collections::HashMap<u64, bool>,
compress_cache: &mut std::collections::HashMap<u64, String>,
conv_id: &str,
iteration: usize,
) -> Vec<ChatMessage> {
@@ -333,7 +334,18 @@ pub(crate) fn summarize_tool_results(
return m;
}
original_bytes += content_len;
let compressed = extract_key_info(&m.content, resolved_name);
// memoization(狗粮实证 369 组同内容反复重压):压缩是确定性纯函数,
// 同内容哈希命中直接复用缓存压缩结果,跳过重扫描。键与 view_decisions
// 同款 tool_view_decision_key(content),tool_name 差异(豁免 read_file)
// 已在上方分支处理,进到此处的都可压缩,故键不含 tool_name。
let compressed = match compress_cache.get(&key) {
Some(cached) => cached.clone(),
None => {
let c = extract_key_info(&m.content, resolved_name);
compress_cache.insert(key, c.clone());
c
}
};
compressed_bytes += compressed.len();
summarized_count += 1;
m.content = compressed;
+10 -4
View File
@@ -746,11 +746,17 @@ async fn run_sub_agent_loop_inner(
msgs.extend(history_msgs);
let history_tokens = conv.messages.history_tokens();
// tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)。
// 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转。
// 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转;
// 压缩结果 memoization 同主 loop(同内容哈希跳过重压)。
let mut view_decisions = conv.tool_view_decisions.clone();
let msgs = summarize_tool_results(msgs, history_tokens, &mut view_decisions, &child_conv_id, round as usize);
if !view_decisions.is_empty() {
session.conv(&child_conv_id).tool_view_decisions = view_decisions;
let mut compress_cache = conv.tool_compress_cache.clone();
let msgs = summarize_tool_results(
msgs, history_tokens, &mut view_decisions, &mut compress_cache, &child_conv_id, round as usize,
);
if !view_decisions.is_empty() || !compress_cache.is_empty() {
let conv = session.conv(&child_conv_id);
conv.tool_view_decisions = view_decisions;
conv.tool_compress_cache = compress_cache;
}
let msgs = ContextManager::assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED);
let est = TokenEstimator::default();
+8
View File
@@ -1073,6 +1073,13 @@ pub struct PerConvState {
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
/// 位置漂移,内容不变)。随会话销毁不落库。
pub tool_view_decisions: std::collections::HashMap<u64, bool>,
/// tool_result 压缩结果 memoization(content 稳定哈希 → 压缩后文本)。
///
/// 狗粮实证(2026-10-04):view-only 压缩 2280 次中 369 组同内容反复重压(最坏 54 次),
/// 纯 CPU 纯函数但大内容重复扫描浪费。压缩是确定性的(content,tool_name)→ 输出,
/// 同内容哈希命中直接复用缓存结果,跳过重算。与 tool_view_decisions 同为内存态
/// 不落库,随会话销毁;false 决策(未压缩)不进缓存(原样返回无结果可记)。
pub tool_compress_cache: std::collections::HashMap<u64, String>,
/// 无人值守任务级授权边界(设计 §二):发起时一次声明,存续期内边界内零审批。
/// None=未声明(现状逐次审批零倒退)。内存真相源在 per_conv,落库随行
/// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。
@@ -1229,6 +1236,7 @@ impl PerConvState {
workflow_dag_summary: None,
frozen_tool_names: None,
tool_view_decisions: std::collections::HashMap::new(),
tool_compress_cache: std::collections::HashMap::new(),
boundary: None,
readonly_cache_floor: 0,
}
+16
View File
@@ -106,6 +106,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
- **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\
- {}\n\
- {}\n\
## Focus\n\
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
@@ -113,6 +114,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- Do not proactively expand context (files/data) that is irrelevant to the current request.\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
file_reading_guidance(lang),
),
"\n## Current Projects\n",
"\n## Current Tasks\n",
@@ -135,6 +137,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
- {}\n\
- {}\n\
## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
@@ -142,6 +145,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
file_reading_guidance(lang),
),
"\n## 当前项目\n",
"\n## 当前任务\n",
@@ -193,6 +197,16 @@ fn duplicate_call_guidance(lang: &str) -> &'static str {
}
}
/// 文件读取纪律指引(中/英,不含行首 "- ")。狗粮实证(2026-10-04):67+ 次 run_command
/// PowerShell/python 切片绕道读文件,而 read_file 本身支持 offset/limit 分页+search 参数。
/// 父会话能力清单与子代理工具纪律段同源拼接(同 read_symbol_guidance 模式)。
fn file_reading_guidance(lang: &str) -> &'static str {
match lang {
"en" => "**File reading discipline**: read_file supports offset/limit pagination and the search parameter (search matching lines inside a file) — use them to read large files in pages or locate content directly. Never use run_command (PowerShell/python scripts) to slice or read files — it is slower, bypasses path authorization and binary detection, and pollutes context; run_command is for running tests/builds/binaries only",
_ => "**文件读取纪律**:read_file 自带 offset/limit 分页与 search 参数(文件内容行级搜索)——大文件用它分页续读、定位内容直接传 search。严禁用 run_command(PowerShell/python 脚本)切片或读取文件——更慢、绕过路径授权与二进制检测、且污染上下文;run_command 仅用于跑测试/构建/二进制等命令执行场景",
}
}
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
@@ -202,10 +216,12 @@ fn duplicate_call_guidance(lang: &str) -> &'static str {
pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
format!(
"\n## 工具纪律\n\
- {}\n\
- {}\n\
- {}\n{}{}",
read_symbol_guidance(lang),
duplicate_call_guidance(lang),
file_reading_guidance(lang),
doc_strategy_core_section(lang),
app_config_query_guidance_section(lang),
)
+20 -3
View File
@@ -46,16 +46,33 @@ pub(crate) async fn ensure_conversation_title(
}
}
// 取对话文本(仅 user/assistant,跳过 tool 噪音),取前 6 条供 LLM 总结
// 取对话文本(仅 user/assistant,跳过 tool 噪音),取前 6 条供 LLM 总结。
// 狗粮实证(2026-10-04):混合 take(6) 在 assistant 消息靠前场景(重提/续接)会把 user 首条挤出,
// LLM 拿 assistant 首句当主题生成标题(如「信息已足够。让我看一眼...」)。改两段式:
// user 首条必进(锚定主题),其余按序补足 6 条。
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参)。
// conv() 惰性建:switch 触发的 spawn_ensure_title 前序 switch 已 conv();若极端未建,
// conv() 建空 PerConvState → summary_msgs 空 → 下方 return(无素材不生成标题)。
let (summary_msgs, all_msgs) = {
let mut session = session_arc.lock().await;
let conv = session.conv(conv_id);
let summary: Vec<ChatMessage> = conv.messages.iter()
let sample: Vec<_> = conv.messages.iter()
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
.take(6)
.collect();
// user 首条下标(无 user 消息则退回纯顺序 take(6),空素材由下方 return 兜住)
let first_user_idx = sample.iter().position(|m| matches!(m.role, MessageRole::User));
let mut picked: Vec<usize> = Vec::with_capacity(6);
if let Some(i) = first_user_idx {
picked.push(i);
}
for (i, _) in sample.iter().enumerate() {
if picked.len() >= 6 { break; }
if picked.contains(&i) { continue; }
picked.push(i);
}
picked.sort_unstable();
let summary: Vec<ChatMessage> = picked.into_iter()
.filter_map(|i| sample.get(i))
.map(|m| ChatMessage {
id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义)
role: m.role.clone(),
+61 -27
View File
@@ -187,10 +187,12 @@ pub(crate) fn truncate_output(s: &str, max: usize) -> (String, bool) {
// ============================================================
// patch_file 三模式共用底层
//
// 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
// 三模式定位(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
// 后两者均归约为「按行号区间 [start, end](1-based 含首尾)替换为 new_text」,
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间。
// 提取为纯函数便于直接单测(不依赖 async/fs/锁),handler 闭包内复用。
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间
// (end 子串须唯一命中,多处命中报错)。多模式同时传入由 handler 层按优先级仲裁
// (见 tools/file.rs arbitrate_patch_mode)。提取为纯函数便于直接单测(不依赖 async/fs/锁),
// handler 闭包内复用。
// ============================================================
/// 按行号区间替换:将 content 第 [start, end] 行(1-based, 含首尾)替换为 new_text。
@@ -242,11 +244,14 @@ pub(crate) fn apply_line_range(content: &str, start: usize, end: usize, new_text
Ok(out)
}
/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后第一个含 `end` 子串的行。
/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后含 `end` 子串的行。
///
/// - 子串匹配(大小写敏感,与 search_files 一致)。
/// - start/end 任一找不到返 Err;start 行号 > end 行号返 Err(即 end 必须在 start 之后或同行)。
/// - 同一行同时命中 start/end:start==end,区间退化为单行替换。
/// - end 唯一性校验(治实测 ×2 多处命中替换错位):end 子串在 start 行(含)之后多处命中
/// 时返 Err,列出前两处命中行上下文,提示缩小 end 锚点或改用 old_text 模式。
/// 旧实现静默取第一个命中行,LLM 意图往往是更靠后的行,错位后返工。
/// - 返回 (start_line, end_line)(1-based 含首尾)。
pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> {
if start.is_empty() {
@@ -265,22 +270,44 @@ pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) ->
let start_line = start_line
.ok_or_else(|| anyhow::anyhow!("anchor.start 子串「{}」未在文件中找到", start))?;
// end 在 start_line 之后(含 start_line 同行)第一个命中的行
let mut end_line: Option<usize> = None;
for (i, line) in content.lines().enumerate() {
let lineno = i + 1;
if lineno < start_line {
continue;
// end 在 start_line 之后(含 start_line 同行)收集全部命中行,唯一才放行
let all_lines: Vec<&str> = content.lines().collect();
let end_hits: Vec<usize> = all_lines
.iter()
.enumerate()
.filter(|(i, line)| i + 1 >= start_line && line.contains(end))
.map(|(i, _)| i + 1)
.collect();
let end_line = match end_hits.len() {
0 => anyhow::bail!(
"anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到",
end, start_line
),
1 => end_hits[0],
_ => {
// 列出前两处命中行原文(截断防超长行刷屏),供 LLM 对照改锚
let ctx = end_hits
.iter()
.take(2)
.map(|&ln| {
let raw = all_lines[ln - 1];
let shown: String = if raw.chars().count() > 80 {
raw.chars().take(80).collect::<String>() + "…"
} else {
raw.to_string()
};
format!("第 {} 行: {}", ln, shown)
})
.collect::<Vec<_>>()
.join("\n");
anyhow::bail!(
"anchor.end 子串「{}」在 start 行(第 {} 行)之后命中 {} 处,定位歧义。前两处:\n{}\n请缩小 end 锚点(加长子串使其唯一)或改用 old_text 模式",
end, start_line, end_hits.len(), ctx
)
}
if line.contains(end) {
end_line = Some(lineno);
break;
}
}
let end_line = end_line
.ok_or_else(|| anyhow::anyhow!("anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到", end, start_line))?;
};
// start_line <= end_line 已由搜索顺序保证,此处断言兜底
// start_line <= end_line 已由命中过滤条件保证,此处断言兜底
debug_assert!(start_line <= end_line, "anchor 解析顺序异常: start={} end={}", start_line, end_line);
Ok((start_line, end_line))
}
@@ -1995,12 +2022,12 @@ mod tests {
assert_eq!((s, e), (1, 4));
}
/// resolve_anchor_to_lines: 子串匹配会在 start 之后命中第一个含该子串的行
/// (含行内出现,如 println!("{}") 内的 })——锁定此行为语义,避免后续误改成「整行相等」
/// resolve_anchor_to_lines: 子串匹配含行内出现(如 println!("{}") 内的 })——
/// end 唯一命中时放行,锁定「子串包含」语义(非「整行相等」)
#[test]
fn test_resolve_anchor_substring_matches_inside_line() {
// end 锚 "}" 在 start 行之后第一个命中 = 第 3 行 println!("{}") 的 },而非第 4 行的 }
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n}\n";
// end 锚 "}" 唯一命中第 3 行 println!("{}") 的 }(第 4 行换成无 } 的行)
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\nEND\n";
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap();
assert_eq!((s, e), (1, 3));
}
@@ -2014,13 +2041,20 @@ mod tests {
assert_eq!((s, e), (1, 1));
}
/// resolve_anchor_to_lines: end 在 start 之后第一个命中行(跳过中间同子串)
/// resolve_anchor_to_lines: end 子串多处命中 → Err 列前两处命中行 + 修复提示
/// (契约变更:旧实现静默取第一个命中行,实测 ×2 LLM 短 end 锚点多处命中替换错位,
/// 根修改为唯一性校验——多处命中报错,提示缩小 end 锚点或改用 old_text)
#[test]
fn test_resolve_anchor_picks_first_end_after_start() {
// 第 2、4 行都含 "}",应取 start(第1行) 之后第一个 "}" → 第 2 行
fn test_resolve_anchor_end_multi_hit_rejected() {
// 第 2、4 行都含 "}",多处命中 → Err
let content = "START\n}\nx\n}";
let (s, e) = resolve_anchor_to_lines(content, "START", "}").unwrap();
assert_eq!((s, e), (1, 2));
let err = resolve_anchor_to_lines(content, "START", "}").unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("命中 2 处"), "应报多处命中数,实际: {msg}");
assert!(msg.contains("第 2 行"), "应列第一处命中行,实际: {msg}");
assert!(msg.contains("第 4 行"), "应列第二处命中行,实际: {msg}");
assert!(msg.contains("缩小 end 锚点"), "应含缩小锚点提示,实际: {msg}");
assert!(msg.contains("old_text"), "应含改用 old_text 建议,实际: {msg}");
}
/// resolve_anchor_to_lines: 大小写敏感(与 search_files 一致)
+230 -32
View File
@@ -71,7 +71,7 @@ pub fn register(
registry,
allowed_dirs: Arc<RwLock<AllowedDirs>>,
"read_file",
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)",
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)。遍历大文件用大 limit(300 左右)分段读,小页(15-40 行)遍历会成倍放大调用轮次",
RiskLevel::Low,
meta: ToolMeta {
path_args: &["path"],
@@ -181,7 +181,7 @@ pub fn register(
let consumed = offset_used.unwrap_or(0) + returned_lines;
let truncated_info = if has_more {
Some(format!(
"文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={} 参数(可配合 limit={limit})",
"文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={}(建议 limit 300,大段遍历远快于小页;精读已定位片段再用小 limit;仅需函数签名用 read_symbol)",
offset_used.map(|o| o.to_string()).unwrap_or_else(|| "0".to_string()),
line_count - consumed,
consumed,
@@ -448,10 +448,10 @@ pub fn register(
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("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 防并发行号漂移",
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一。配 expected_hash 防并发行号漂移",
"properties": {
"start": { "type": "integer", "description": "起始行号(1-based,含)" },
"end": { "type": "integer", "description": "结束行号(1-based,含)" }
@@ -460,10 +460,10 @@ pub fn register(
}));
props.insert("anchor".into(), serde_json::json!({
"type": "object",
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文",
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一。不需完整原文",
"properties": {
"start": { "type": "string", "description": "首行子串标记(大小写敏感)" },
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" }
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后,须唯一命中;多处命中会报错并列出命中位置)" }
},
"required": ["start", "end"]
}));
@@ -479,7 +479,7 @@ pub fn register(
registry,
allowed_dirs: Arc<RwLock<AllowedDirs>>,
"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 表可追溯。",
"局部更新文件内容(三模式定位)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义);精确未中时自动按空白归一化(行首缩进归一+多空白折叠)重试,唯一命中即按整行区间替换并在 warning 注明「空白归一化命中」。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文;end 锚点须唯一命中,多处命中报错并列出前两处位置(届时缩小 end 子串或改用 old_text)。三个定位参数同时传入时按优先级 anchor > old_text > replace_lines 仲裁取一执行并在 warning 注明,全缺才报错。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
RiskLevel::Medium,
meta: ToolMeta {
path_args: &["path"],
@@ -500,8 +500,11 @@ pub fn register(
let new_text = args["new_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
// 三模式互斥校验(old_text / replace_lines / anchor)
// 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式)
// 三模式定位(old_text / replace_lines / anchor)
// 旧实现:三选一互斥,>1 直接拒绝。实测 LLM 反复同时传 old_text+anchor(×3)
// 被拒后重试仍传双参,空耗往返。改:多模式按优先级仲裁取一执行
// (anchor > old_text > replace_lines,见 arbitrate_patch_mode),
// result.warning 注明仲裁结果;全缺时才报参数缺失。
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);
@@ -509,12 +512,8 @@ pub fn register(
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 (use_anchor, use_old_text, use_replace_lines, mode_arbitration) =
arbitrate_patch_mode(has_anchor, has_old_text, has_replace_lines);
// 文件存在性 / 大小限制(三模式通用,先于内容读取)
let target = std::path::Path::new(path);
@@ -572,7 +571,7 @@ pub fn register(
let match_count: usize;
let warning: Option<String>;
if has_old_text {
if use_old_text {
let old_text = args["old_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
if old_text.is_empty() {
@@ -583,11 +582,26 @@ pub fn register(
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
}));
}
// L2: old_text 精确匹配(CAS 语义)
// 失败时附相近片段提示把文件里与 old_text 相似的几行列给 LLM,
// 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则
// 退回原提示,不误导。
if !content.contains(old_text) {
// L2: old_text 精确匹配(CAS 语义)→ 空白归一化回退 → 相近片段诊断。
// 实测 ×15 失败是 LLM 缩进/空白对不齐:精确未中时按「行首缩进归一+多空白
// 折叠」归一化重匹配,唯一命中自动采用(治根),多命中/零命中维持原报错,
// 附相近片段供 LLM 对照真实缩进修正(AC-5)。
if content.contains(old_text) {
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 let Some((sl, el)) = find_whitespace_normalized_match(&content, old_text) {
// 归一化唯一命中:按整行区间替换(old_text 归一化行与目标行全等,
// 首尾行无需保留的行内其他内容)。warning 注明命中方式,LLM 可知情复核。
new_content = apply_line_range(&content, sl, el, new_text)?;
match_count = 1;
warning = Some(format!(
"空白归一化命中(精确匹配未中,第 {sl}-{el} 行已按整行区间替换);建议重读文件核对实际缩进"
));
} else {
let similar = similar_line_fragments(&content, old_text, 3);
if similar.is_empty() {
anyhow::bail!("未找到目标文本,文件可能已被修改");
@@ -597,13 +611,7 @@ pub fn register(
similar.join("\n")
);
}
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 {
} else if use_replace_lines {
let rl = args.get("replace_lines")
.ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
let start = rl["start"].as_u64()
@@ -614,7 +622,7 @@ pub fn register(
match_count = 1;
warning = None;
} else {
// has_anchor
// use_anchor
let an = args.get("anchor")
.ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
let a_start = an["start"].as_str()
@@ -652,15 +660,31 @@ pub fn register(
// 生成 unified diff 供前端审批卡/审计留痕展示
// content(旧)与 new_content(新)作用域内可用,无需重读文件
let diff = generate_diff(&content, &new_content);
let mode_name = if use_anchor {
"anchor"
} else if use_old_text {
"old_text"
} else {
"replace_lines"
};
let mut result = serde_json::json!({
"path": path,
"changed": true,
"mode": mode_name,
"size_diff": size_diff,
"matches_found": match_count,
"diff": diff,
});
// 汇总提示:多模式仲裁说明 + 分支 warning(空白归一化命中/多匹配仅换第 1 处)
let mut notes: Vec<String> = Vec::new();
if let Some(a) = mode_arbitration {
notes.push(a);
}
if let Some(w) = warning {
result["warning"] = serde_json::Value::String(w);
notes.push(w);
}
if !notes.is_empty() {
result["warning"] = serde_json::Value::String(notes.join(";"));
}
Ok(result)
}
@@ -1032,7 +1056,13 @@ pub fn register(
// 旧实现直接 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))?;
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
file_not_found_error(root)
} else {
anyhow::anyhow!("无法访问路径 {}: {}", root, e)
}
})?;
if root_meta.is_file() {
let file_name = std::path::Path::new(root)
.file_name()
@@ -1379,7 +1409,7 @@ async fn read_text_file(path: &str) -> anyhow::Result<(String, u64)> {
let mut file = File::open(path).await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!("文件不存在: {}", path)
file_not_found_error(path)
} else {
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
}
@@ -1473,6 +1503,84 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String
scored.into_iter().map(|(_, s)| s).collect()
}
// ============================================================
// patch_file 多模式仲裁 + 空白归一化回退 — 纯函数(便于单测,handler 内复用)
// ============================================================
/// patch_file 多模式仲裁:同时传入多个定位参数时按优先级取一执行。
///
/// 优先级 anchor > old_text > replace_lines(anchor 语义最精确,行号区间次之)。
/// 返回 (use_anchor, use_old_text, use_replace_lines, 仲裁说明):单模式时原样放行、
/// 说明为 None;多模式时只开选中开关,说明含「检测到多模式,已按 X 执行」。
/// 全 false(缺定位方式)由调用方先行报错,本函数不处理。
fn arbitrate_patch_mode(
has_anchor: bool,
has_old_text: bool,
has_replace_lines: bool,
) -> (bool, bool, bool, Option<String>) {
let count = [has_anchor, has_old_text, has_replace_lines]
.iter()
.filter(|&&b| b)
.count();
if count <= 1 {
return (has_anchor, has_old_text, has_replace_lines, None);
}
let chosen = if has_anchor {
"anchor"
} else if has_old_text {
"old_text"
} else {
"replace_lines"
};
(
chosen == "anchor",
chosen == "old_text",
chosen == "replace_lines",
Some(format!(
"检测到多模式({count} 个),已按 {chosen} 执行(优先级 anchor > old_text > replace_lines)"
)),
)
}
/// 单行空白归一化:所有空白序列(行首缩进/制表符/连续空格)折叠为单个空格。
/// " let x = 1;" 归一化为 "let x = 1;"。
fn normalize_ws_line(line: &str) -> String {
line.split_whitespace().collect::<Vec<&str>>().join(" ")
}
/// 空白归一化行序列匹配:old_text 各行归一化后在 content 归一化行序列中找连续命中。
///
/// 用途:patch_file old_text 精确匹配未中时的回退(实测 ×15 缩进/空白对不齐)。
/// 仅当归一化后**唯一**命中才返回 Some((start_line, end_line))(1-based 含首尾);
/// 多命中(歧义)/零命中返回 None,调用方维持原报错(含相近片段诊断)。
/// 匹配为整行粒度:old_text 各归一化行须与目标行全等,故命中区间按整行替换不丢内容。
fn find_whitespace_normalized_match(content: &str, old_text: &str) -> Option<(usize, usize)> {
let target: Vec<String> = old_text.lines().map(normalize_ws_line).collect();
// old_text 归一化后全空行 → 无意义锚点,不回退(交由精确路径报错)
if target.iter().all(|l| l.is_empty()) {
return None;
}
let hay: Vec<String> = content.lines().map(normalize_ws_line).collect();
let m = target.len();
if hay.len() < m {
return None;
}
let mut hits: Vec<usize> = Vec::new();
for i in 0..=hay.len() - m {
if hay[i..i + m] == target[..] {
hits.push(i);
if hits.len() > 1 {
break; // 多命中即歧义,提前终止
}
}
}
if hits.len() == 1 {
Some((hits[0] + 1, hits[0] + m))
} else {
None
}
}
// ============================================================
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选
// ============================================================
@@ -1742,6 +1850,96 @@ mod tests {
);
}
// ============================================================
// patch_file 多模式仲裁 + 空白归一化回退 — 三态锁死(命中/多命中/零命中)
// ============================================================
#[test]
fn arbitrate_single_mode_passthrough_no_note() {
// 单模式/零模式:开关原样放行,无仲裁说明
assert_eq!(arbitrate_patch_mode(false, true, false), (false, true, false, None));
assert_eq!(arbitrate_patch_mode(false, false, true), (false, false, true, None));
assert_eq!(arbitrate_patch_mode(true, false, false), (true, false, false, None));
assert_eq!(arbitrate_patch_mode(false, false, false), (false, false, false, None));
}
#[test]
fn arbitrate_multi_mode_prefers_anchor_then_old_text() {
// 优先级 anchor > old_text > replace_lines,仲裁说明含选中模式名
let (a, o, r, note) = arbitrate_patch_mode(true, true, true);
assert!(a && !o && !r, "三模式全传应选 anchor");
assert!(note.unwrap().contains("anchor"));
let (a, o, r, note) = arbitrate_patch_mode(false, true, true);
assert!(!a && o && !r, "old_text+replace_lines 应选 old_text");
assert!(note.unwrap().contains("old_text"));
let (a, o, r, _) = arbitrate_patch_mode(true, false, true);
assert!(a && !o && !r, "anchor+replace_lines 应选 anchor");
// 仲裁说明措辞契约(LLM 依赖该文案知情):「检测到多模式」「已按 X 执行」
let (_, _, _, note) = arbitrate_patch_mode(true, true, false);
let text = note.unwrap();
assert!(text.contains("检测到多模式"), "实际: {text}");
assert!(text.contains("已按 anchor 执行"), "实际: {text}");
}
#[test]
fn normalize_ws_line_collapses_all_whitespace() {
assert_eq!(normalize_ws_line(" let x\t= 1; "), "let x = 1;");
assert_eq!(normalize_ws_line(""), "");
assert_eq!(normalize_ws_line("\t\t"), "");
}
#[test]
fn normalized_match_unique_indent_drift_hits() {
// 实测主失败形态:缩进漂移(2/6 空格 vs LLM 传 4 空格)+ 行内多空白 → 唯一命中
let content = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
let old = " let y = 2;";
assert_eq!(find_whitespace_normalized_match(content, old), Some((3, 3)));
}
#[test]
fn normalized_match_multiline_block_hits() {
// 多行块(含 tab 缩进):归一化后行序列连续命中第 2-3 行
let content = "a\n one two;\n\tthree;\nb\n";
let old = "one two;\n three;";
assert_eq!(find_whitespace_normalized_match(content, old), Some((2, 3)));
}
#[test]
fn normalized_match_exact_compatible_scenario() {
// 精确匹配本可命中的场景,归一化同样定位到同区间(handler 精确优先,此为一致性)
let content = "let x = 1;\n";
assert_eq!(find_whitespace_normalized_match(content, "let x = 1;"), Some((1, 1)));
}
#[test]
fn normalized_match_multi_hit_returns_none() {
// 多命中 = 歧义 → None(handler 维持报错路径,不瞎选)
let content = "x = 1;\ny = 2;\nx = 1;\n";
assert_eq!(find_whitespace_normalized_match(content, "x = 1;"), None);
}
#[test]
fn normalized_match_no_hit_returns_none() {
// 零命中 → None(handler 维持相近片段诊断报错)
let content = "fn a() {}\nfn b() {}\n";
assert_eq!(find_whitespace_normalized_match(content, "fn totally_other() {}"), None);
}
#[test]
fn normalized_match_blank_old_text_returns_none() {
// 全空行 old_text → None(交由精确路径报错)
assert_eq!(find_whitespace_normalized_match("a\n\nb\n", "\n\n"), None);
}
#[test]
fn normalized_match_range_feeds_line_replace_e2e() {
// 归一化命中行区间 → apply_line_range 整行替换(handler 同款调用链)
let content = "keep\n let x = 1;\n let y = 2;\nkeep\n";
let (sl, el) = find_whitespace_normalized_match(content, " let x = 1;").unwrap();
let out = apply_line_range(content, sl, el, "REPLACED").unwrap();
assert_eq!(out, "keep\nREPLACED\n let y = 2;\nkeep\n");
}
// ============================================================
// similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选
// ============================================================