From a86781e1265bdd42061964a74fb77b796afd34af Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 6 May 2026 16:53:50 +0800 Subject: [PATCH] feat(tools): tighten edit_file replace rules and feedback --- core/main_terminal_parts/tools_definition.py | 7 +- core/main_terminal_parts/tools_execution.py | 5 +- easyagent/src/tools/dispatcher.js | 3 +- easyagent/src/tools/edit_file.js | 65 ++++++++++++++----- modules/file_manager.py | 47 ++++++++++++-- prompts/main_system.txt | 2 +- prompts/main_system_qwenvl.txt | 2 +- scripts/api_tool_role_experiment.py | 9 ++- server/chat_flow_tool_loop.py | 19 +++++- .../components/chat/actions/ToolAction.vue | 25 +++++-- .../components/chat/actions/toolRenderers.ts | 25 +++++-- 11 files changed, 169 insertions(+), 40 deletions(-) diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 82b7e9d..1e76cf1 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -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"] } } }, diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index b927a11..3e3963e 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -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: diff --git a/easyagent/src/tools/dispatcher.js b/easyagent/src/tools/dispatcher.js index 44c86ae..e6a3b57 100644 --- a/easyagent/src/tools/dispatcher.js +++ b/easyagent/src/tools/dispatcher.js @@ -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) { diff --git a/easyagent/src/tools/edit_file.js b/easyagent/src/tools/edit_file.js index 4d3cd20..319d276 100644 --- a/easyagent/src/tools/edit_file.js +++ b/easyagent/src/tools/edit_file.js @@ -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) { diff --git a/modules/file_manager.py b/modules/file_manager.py index cd84f03..22f321f 100644 --- a/modules/file_manager.py +++ b/modules/file_manager.py @@ -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: """清空文件内容""" diff --git a/prompts/main_system.txt b/prompts/main_system.txt index 84ccc62..0ebc36f 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -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` 抽取,避免全文读取。 diff --git a/prompts/main_system_qwenvl.txt b/prompts/main_system_qwenvl.txt index e41502b..6280840 100644 --- a/prompts/main_system_qwenvl.txt +++ b/prompts/main_system_qwenvl.txt @@ -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` 抽取,避免全文读取。 diff --git a/scripts/api_tool_role_experiment.py b/scripts/api_tool_role_experiment.py index 463d00a..c33ce8f 100644 --- a/scripts/api_tool_role_experiment.py +++ b/scripts/api_tool_role_experiment.py @@ -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"] } } } diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index cf13f07..94e947a 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -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 diff --git a/static/src/components/chat/actions/ToolAction.vue b/static/src/components/chat/actions/ToolAction.vue index 00785fd..11d641d 100644 --- a/static/src/components/chat/actions/ToolAction.vue +++ b/static/src/components/chat/actions/ToolAction.vue @@ -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 = '