feat(tools): tighten edit_file replace rules and feedback

This commit is contained in:
JOJO 2026-05-06 16:53:50 +08:00
parent e58a962dcd
commit a86781e126
11 changed files with 169 additions and 40 deletions

View File

@ -540,7 +540,7 @@ class MainTerminalToolsDefinitionMixin:
},
"old_string": {
"type": "string",
"description": "要替换的文本(需与文件内容精确匹配,保留缩进"
"description": "要替换的文本(需与文件内容精确匹配,保留缩进建议提供至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行"
},
"new_string": {
"type": "string",
@ -549,11 +549,10 @@ class MainTerminalToolsDefinitionMixin:
"replace_all": {
"type": "boolean",
"enum": [True, False],
"description": "是否替换所有匹配内容。false=仅替换首个匹配true=替换全部匹配。",
"default": False
"description": "是否替换所有匹配内容必填。false=仅替换首个匹配true=替换全部匹配。"
}
}),
"required": ["file_path", "old_string", "new_string"]
"required": ["file_path", "old_string", "new_string", "replace_all"]
}
}
},

View File

@ -1065,11 +1065,14 @@ class MainTerminalToolsExecutionMixin:
path = arguments.get("file_path")
old_text = arguments.get("old_string")
new_text = arguments.get("new_string")
replace_all = arguments.get("replace_all", False)
replace_all_provided = "replace_all" in arguments
replace_all = arguments.get("replace_all") if replace_all_provided else None
if not path:
result = {"success": False, "error": "缺少必要参数: file_path"}
elif old_text is None or new_text is None:
result = {"success": False, "error": "缺少必要参数: old_string/new_string"}
elif not replace_all_provided:
result = {"success": False, "error": "缺少必要参数: replace_all必须显式传入 true 或 false"}
elif not isinstance(replace_all, bool):
result = {"success": False, "error": "replace_all 必须是 true 或 false"}
elif old_text == new_text:

View File

@ -46,7 +46,8 @@ function formatReadFile(result) {
function formatEditFile(result) {
if (!result.success) return formatFailure(result.error || '修改失败');
const count = typeof result.replacements === 'number' ? result.replacements : 0;
return `已替换 ${count} 处: ${result.path}`;
const msg = result.message ? `${result.message}` : '';
return `已替换 ${count} 处: ${result.path}${msg}`;
}
function formatWebSearch(result) {

View File

@ -41,32 +41,43 @@ function diffSummary(oldText, newText) {
return { added, removed, hunks };
}
function countLines(text) {
if (!text) return 0;
return String(text).split(/\r?\n/).length;
}
function findMatchLineNumbers(content, target) {
if (!target) return [];
const lines = [];
let start = 0;
while (true) {
const idx = content.indexOf(target, start);
if (idx === -1) break;
const lineNo = content.slice(0, idx).split(/\r?\n/).length;
lines.push(lineNo);
start = idx + Math.max(1, target.length);
}
return lines;
}
function editFileTool(workspace, args) {
const target = resolvePath(workspace, args.file_path);
const oldString = args.old_string ?? '';
const newString = args.new_string ?? '';
const replaceAll = args.replace_all ?? false;
if (!Object.prototype.hasOwnProperty.call(args || {}, 'replace_all')) {
return { success: false, error: '缺少必要参数: replace_all必须显式传入 true 或 false' };
}
const replaceAll = args.replace_all;
if (typeof replaceAll !== 'boolean') {
return { success: false, error: 'replace_all 必须是 true 或 false' };
}
const shortOldStringNotice = countLines(oldString) < 3;
const ctx = getContainerContext();
if (ctx) {
const resolved = resolveContainerPath(workspace, args.file_path || '.', ctx);
if (resolved.error) return { success: false, error: resolved.error };
const rel = resolved.relativePath;
if (!oldString) {
const writeResp = execContainerAction(ctx, 'write_file', {
path: rel,
content: newString || '',
mode: 'w',
});
if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' };
return {
success: true,
path: resolved.containerPath,
replacements: newString ? 1 : 0,
};
}
if (!oldString) return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' };
const readResp = execContainerAction(ctx, 'read_file', { path: rel });
if (!readResp.success) return { success: false, error: readResp.error || '读取失败' };
@ -74,6 +85,8 @@ function editFileTool(workspace, args) {
if (!original.includes(oldString)) {
return { success: false, error: 'old_string 未匹配到内容' };
}
const matchedLines = findMatchLineNumbers(original, oldString);
const foundCount = matchedLines.length;
const replacements = replaceAll ? (original.split(oldString).length - 1) : 1;
const updated = replaceAll
? original.split(oldString).join(newString)
@ -85,10 +98,20 @@ function editFileTool(workspace, args) {
});
if (!writeResp.success) return { success: false, error: writeResp.error || '写入失败' };
const diff = diffSummary(original, updated);
const messageParts = [];
if (shortOldStringNotice) {
messageParts.push('提示old_string 少于3行已继续执行需要批量替换的场景可以单行或不足一行');
}
if (foundCount > 1) {
messageParts.push(`发现${foundCount}处,于${matchedLines.join(',')}行共替换${replacements}`);
}
return {
success: true,
path: resolved.containerPath,
replacements,
found_matches: foundCount,
matched_lines: matchedLines,
message: messageParts.length > 0 ? messageParts.join('') : undefined,
diff,
};
}
@ -104,12 +127,14 @@ function editFileTool(workspace, args) {
return { success: false, error: '目标不是文件' };
}
const original = fs.readFileSync(target, 'utf8');
if (!creating && oldString === '') {
if (oldString === '') {
return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' };
}
if (!creating && !original.includes(oldString)) {
return { success: false, error: 'old_string 未匹配到内容' };
}
const matchedLines = creating ? [] : findMatchLineNumbers(original, oldString);
const foundCount = matchedLines.length;
const updated = creating
? newString
: (replaceAll ? original.split(oldString).join(newString) : original.replace(oldString, newString));
@ -117,10 +142,20 @@ function editFileTool(workspace, args) {
if (creating && newString) replacements = 1;
fs.writeFileSync(target, updated, 'utf8');
const diff = diffSummary(original, updated);
const messageParts = [];
if (shortOldStringNotice) {
messageParts.push('提示old_string 少于3行已继续执行需要批量替换的场景可以单行或不足一行');
}
if (foundCount > 1) {
messageParts.push(`发现${foundCount}处,于${matchedLines.join(',')}行共替换${replacements}`);
}
return {
success: true,
path: target,
replacements,
found_matches: foundCount,
matched_lines: matchedLines,
message: messageParts.length > 0 ? messageParts.join('') : undefined,
diff,
};
} catch (err) {

View File

@ -4,6 +4,7 @@ import os
import shutil
from pathlib import Path
import re
from bisect import bisect_right
from typing import Optional, Dict, List, Set, Tuple, TYPE_CHECKING
from datetime import datetime
try:
@ -1200,15 +1201,19 @@ class FileManager:
"error": "替换的新文本过长,建议分块处理",
"suggestion": "请将大内容分成多个小的替换操作"
}
short_old_text_notice = bool(old_text and len(old_text.splitlines()) < 3)
# 检查是否包含要替换的内容
if old_text and old_text not in content:
return {"success": False, "error": "未找到要替换的内容"}
matched_lines = self._find_match_line_numbers(content, old_text) if old_text else []
found_count = len(matched_lines) if old_text else 0
# 替换内容
if old_text:
if replace_all:
count = content.count(old_text)
count = found_count
new_content = content.replace(old_text, new_text)
else:
count = 1
@ -1222,9 +1227,43 @@ class FileManager:
result = self.write_file(path, new_content)
if result["success"]:
result["replacements"] = count
message_parts: List[str] = []
if old_text:
result["found_matches"] = found_count
result["matched_lines"] = matched_lines
if found_count > 1:
line_text = ",".join(str(line_no) for line_no in matched_lines)
message_parts.append(f"发现{found_count}处,于{line_text}行共替换{count}")
if short_old_text_notice:
message_parts.insert(
0,
"提示old_string 少于3行已继续执行需要批量替换的场景可以单行或不足一行"
)
if message_parts:
result["message"] = "".join(message_parts)
print(f"{OUTPUT_FORMATS['file']} 替换了 {count} 处内容")
return result
@staticmethod
def _find_match_line_numbers(content: str, target: str) -> List[int]:
"""返回 target 在 content 中每个匹配起始位置对应的行号1-based"""
if not target:
return []
newline_positions = [idx for idx, ch in enumerate(content) if ch == "\n"]
line_numbers: List[int] = []
search_start = 0
target_len = len(target)
while True:
idx = content.find(target, search_start)
if idx < 0:
break
line_no = bisect_right(newline_positions, idx) + 1
line_numbers.append(line_no)
search_start = idx + max(1, target_len)
return line_numbers
def clear_file(self, path: str) -> Dict:
"""清空文件内容"""

View File

@ -87,7 +87,7 @@
- `create_file`:创建空文件(禁止在根目录创建,需先建子目录)
- `write_file`:写入文件(`append` 控制覆盖/追加)
- `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式)
- `edit_file`:精确字符串替换(`replace_all` 可选,默认 `false`,仅替换首个匹配;为 `true` 时替换全部匹配
- `edit_file`:精确字符串替换(`replace_all` 必填,必须显式传入 `true/false``old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行
- `delete_file` / `rename_file` / `create_folder`:文件管理
**注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。

View File

@ -87,7 +87,7 @@
- `create_file`:创建空文件(禁止在根目录创建,需先建子目录)
- `write_file`:写入文件(`append` 控制覆盖/追加)
- `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式)
- `edit_file`:精确字符串替换(`replace_all` 可选,默认 `false`,仅替换首个匹配;为 `true` 时替换全部匹配
- `edit_file`:精确字符串替换(`replace_all` 必填,必须显式传入 `true/false``old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行
- `delete_file` / `rename_file` / `create_folder`:文件管理
**注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。

View File

@ -79,11 +79,14 @@ def minimal_tool_definitions() -> List[Dict[str, Any]]:
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "目标文件的相对路径"},
"old_string": {"type": "string", "description": "需要替换的原文"},
"old_string": {
"type": "string",
"description": "需要替换的原文建议至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行"
},
"new_string": {"type": "string", "description": "替换后的新内容"},
"replace_all": {"type": "boolean", "description": "是否替换全部匹配", "default": False}
"replace_all": {"type": "boolean", "description": "是否替换全部匹配(必填)"}
},
"required": ["file_path", "old_string", "new_string"]
"required": ["file_path", "old_string", "new_string", "replace_all"]
}
}
}

View File

@ -45,12 +45,26 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
file_path = args.get("file_path")
old_string = args.get("old_string")
new_string = args.get("new_string")
replace_all = args.get("replace_all", False)
replace_all_provided = "replace_all" in args
replace_all = args.get("replace_all") if replace_all_provided else None
preview["file_path"] = file_path
preview["replace_all"] = replace_all
if not file_path:
preview["summary"] = "缺少 file_path"
return preview
if old_string is None or new_string is None:
preview["summary"] = "缺少 old_string/new_string"
return preview
if not replace_all_provided:
preview["summary"] = "缺少 replace_all必须显式传入 true 或 false"
return preview
if not isinstance(replace_all, bool):
preview["summary"] = "replace_all 必须是 true 或 false"
return preview
old_text = str(old_string or "")
short_old_text_notice = len(old_text.splitlines()) < 3
if short_old_text_notice:
preview["notice"] = "提示old_string 少于3行允许继续执行需要批量替换的场景可以单行或不足一行"
try:
valid, err, full_path = web_terminal.file_manager._validate_path(str(file_path))
if not valid or full_path is None:
@ -62,7 +76,6 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
preview["summary"] = "目标文件不存在,无法生成上下文预览"
return preview
content = full_path.read_text(encoding="utf-8", errors="ignore")
old_text = str(old_string or "")
new_text = str(new_string or "")
old_lines = old_text.splitlines()
new_lines = new_text.splitlines()
@ -96,6 +109,8 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
"old_end_line": None,
}
preview["summary"] = "未在文件中定位到 old_string显示原始替换内容"
if short_old_text_notice:
preview["summary"] = f"{preview['summary']}old_string 少于3行已告知并继续"
except Exception as exc:
preview["summary"] = f"生成编辑预览失败: {exc}"
return preview

View File

@ -270,14 +270,19 @@ function renderWriteFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已写入');
const content = args.content || '';
const isAppend = args.append || false;
const appendFlag = typeof args.append === 'boolean' ? args.append : null;
const modeRaw = String(result.mode || '').toLowerCase();
const writeMode =
appendFlag === true || modeRaw === 'a'
? '追加'
: appendFlag === false || modeRaw === 'w'
? '覆盖'
: '无';
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
if (isAppend) {
html += `<div><strong>模式:</strong>追加</div>`;
}
html += `<div><strong>模式:</strong>${writeMode}</div>`;
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
@ -299,6 +304,15 @@ function renderWriteFile(result: any, args: any): string {
function renderEditFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已编辑');
const replaceAllValue =
typeof args.replace_all === 'boolean' ? (args.replace_all ? '开' : '关') : '无';
const foundCount =
typeof result.found_matches === 'number'
? result.found_matches
: typeof result.replacements === 'number'
? result.replacements
: null;
const replacedCount = typeof result.replacements === 'number' ? result.replacements : null;
//
// 1. args.replacements
@ -310,6 +324,9 @@ function renderEditFile(result: any, args: any): string {
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>替换全部:</strong>${replaceAllValue}</div>`;
html += `<div><strong>找到:</strong>${foundCount ?? '无'}处</div>`;
html += `<div><strong>替换:</strong>${replacedCount ?? '无'}处</div>`;
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}

View File

@ -210,14 +210,19 @@ function renderWriteFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已写入');
const content = args.content || '';
const isAppend = args.append || false;
const appendFlag = typeof args.append === 'boolean' ? args.append : null;
const modeRaw = String(result.mode || '').toLowerCase();
const writeMode =
appendFlag === true || modeRaw === 'a'
? '追加'
: appendFlag === false || modeRaw === 'w'
? '覆盖'
: '无';
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
if (isAppend) {
html += `<div><strong>模式:</strong>追加</div>`;
}
html += `<div><strong>模式:</strong>${writeMode}</div>`;
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
@ -239,6 +244,15 @@ function renderWriteFile(result: any, args: any): string {
function renderEditFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已编辑');
const replaceAllValue =
typeof args.replace_all === 'boolean' ? (args.replace_all ? '开' : '关') : '无';
const foundCount =
typeof result.found_matches === 'number'
? result.found_matches
: typeof result.replacements === 'number'
? result.replacements
: null;
const replacedCount = typeof result.replacements === 'number' ? result.replacements : null;
// 兼容两种数据格式:
// 1. 新格式args.replacements 数组
@ -250,6 +264,9 @@ function renderEditFile(result: any, args: any): string {
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>替换全部:</strong>${replaceAllValue}</div>`;
html += `<div><strong>找到:</strong>${foundCount ?? '无'}处</div>`;
html += `<div><strong>替换:</strong>${replacedCount ?? '无'}处</div>`;
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}