feat(tools): tighten edit_file replace rules and feedback
This commit is contained in:
parent
e58a962dcd
commit
a86781e126
@ -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"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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:
|
||||
"""清空文件内容"""
|
||||
|
||||
@ -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` 抽取,避免全文读取。
|
||||
|
||||
@ -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` 抽取,避免全文读取。
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user