Improve write_file fallback handling

This commit is contained in:
wuyang6
2026-05-12 19:52:41 +08:00
parent a48c86501a
commit 64787498dd
4 changed files with 93 additions and 21 deletions
+14 -2
View File
@@ -216,7 +216,19 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str:
if 'edit_file' in enabled_tool_names: if 'edit_file' in enabled_tool_names:
items.append('编辑文件时,优先使用 edit_file,而不是 shell 文本替换。') items.append('编辑文件时,优先使用 edit_file,而不是 shell 文本替换。')
if 'write_file' in enabled_tool_names: if 'write_file' in enabled_tool_names:
items.append('创建文件时,优先使用 write_file,而不是 heredoc 或 echo 重定向;默认写入当前 session 目录。') items.append(
'创建或追加通用文本文件时,优先使用 write_file,而不是 heredoc 或 echo 重定向;默认写入当前 session 目录。'
)
items.append(
'write_file 支持 content、content_lines、content_base64 三种写入方式。多行脚本、Markdown、CSV 或包含较多引号的文本,优先用 content_lines,避免把大段带转义的内容塞进单个 JSON 字符串。'
)
items.append(
'需要分批写入长文件时,使用 write_file 的 append=true 追加稳定文件;不要反复覆盖同一个中间产物。'
)
if 'python_exec' in enabled_tool_names:
items.append(
'如果 write_file 因内容过长、引号/换行转义复杂或工具参数生成失败而无法稳定完成写入,下一轮应优先改用 python_exec 写文件:把文本作为 stdin 或 Python 字符串/列表处理,并写入同一个 session 路径。'
)
if 'glob_search' in enabled_tool_names: if 'glob_search' in enabled_tool_names:
items.append('搜索文件时,优先使用 glob_search,而不是 find 或 ls。') items.append('搜索文件时,优先使用 glob_search,而不是 find 或 ls。')
if 'grep_search' in enabled_tool_names: if 'grep_search' in enabled_tool_names:
@@ -246,7 +258,7 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str:
'当没有专用工具,或需要真实 shell 语义、系统命令、进程控制、git 只读检查或用户已确认的依赖安装时,可以使用 bash。' '当没有专用工具,或需要真实 shell 语义、系统命令、进程控制、git 只读检查或用户已确认的依赖安装时,可以使用 bash。'
) )
if 'python_exec' in enabled_tool_names: if 'python_exec' in enabled_tool_names:
items.append('当 python_exec 可用时,不要用 bash 执行 python、python3 或 .venv/bin/python。') items.append('当 python_exec 可用时,不要用 bash 执行 python、python3 或 .venv/bin/python;只有 python_exec 和专用文件工具都无法完成时,才把 bash 作为最后兜底')
items.append( items.append(
'你可以在一次响应中调用多个工具。独立工具调用尽量并行,存在依赖关系的调用保持顺序执行。' '你可以在一次响应中调用多个工具。独立工具调用尽量并行,存在依赖关系的调用保持顺序执行。'
) )
+4 -5
View File
@@ -1614,11 +1614,10 @@ class LocalCodingAgent:
f'工具:{name};位置:line {exc.lineno} column {exc.colno}' f'工具:{name};位置:line {exc.lineno} column {exc.colno}'
f'原因:{exc.msg};参数片段:{preview!r}' f'原因:{exc.msg};参数片段:{preview!r}'
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;' '请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
'对于 product-data 生成数据,不要用 write_file 写大段 draft' '如果是 write_file 写多行文本,应改用 content_lines 或 content_base64'
'应每批最多 8 条,直接用 python_exec 的 script_path 模式调用 ' '如果 write_file 仍不稳定,应改用 python_exec 作为通用写文件兜底;'
'skills/product-data/scripts/normalize_dataset_draft.py' '如果是生成/转换结构化数据,应优先调用对应 skill 脚本或 python_exec'
'通过 stdin 传入小批量 JSON,并把 normalize 结果追加到 ' '不要把大段 JSON/CSV/draft 直接塞进工具参数;bash 仅作为最后兜底。'
'scratchpad/normalized_records.jsonl。'
) from exc ) from exc
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
raise OpenAICompatError( raise OpenAICompatError(
+24 -5
View File
@@ -38,14 +38,34 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
), ),
AgentTool( AgentTool(
name='write_file', name='write_file',
description='Write a complete file inside the workspace. Creates parent directories when needed.', description=(
'Write or append a UTF-8 text file inside the workspace. Creates parent directories when needed. '
'For multi-line content, prefer content_lines to reduce JSON escaping errors.'
),
parameters={ parameters={
'type': 'object', 'type': 'object',
'properties': { 'properties': {
'path': {'type': 'string'}, 'path': {'type': 'string', 'description': 'Relative path. output/, scratchpad/, and input/ route to the current session.'},
'content': {'type': 'string'}, 'content': {'type': 'string', 'description': 'Full text content for short or simple files.'},
'content_lines': {
'type': 'array',
'items': {'type': 'string'},
'description': 'Lines to join with "\\n"; recommended for scripts, markdown, CSV, and other multi-line text.',
}, },
'required': ['path', 'content'], 'content_base64': {
'type': 'string',
'description': 'Base64-encoded UTF-8 content. Use only when content contains many quotes or complex escaping.',
},
'append': {
'type': 'boolean',
'description': 'Append to an existing file instead of replacing it. Defaults to false.',
},
'newline_at_end': {
'type': 'boolean',
'description': 'Ensure the written text ends with a newline. Defaults to false.',
},
},
'required': ['path'],
}, },
handler=resolve_handler(handlers, 'write_file', 'file'), handler=resolve_handler(handlers, 'write_file', 'file'),
), ),
@@ -108,4 +128,3 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]:
handler=resolve_handler(handlers, 'grep_search', 'file'), handler=resolve_handler(handlers, 'grep_search', 'file'),
), ),
] ]
+51 -9
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import base64
import hashlib import hashlib
import json import json
import os import os
@@ -1824,23 +1825,34 @@ def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str
_ensure_write_allowed(context) _ensure_write_allowed(context)
target = _resolve_path(_require_string(arguments, 'path'), context) target = _resolve_path(_require_string(arguments, 'path'), context)
_ensure_not_platform_code_write(target, context) _ensure_not_platform_code_write(target, context)
content = arguments.get('content') content = _coerce_write_file_content(arguments)
if not isinstance(content, str): append = arguments.get('append', False)
raise ToolExecutionError('content must be a string') if not isinstance(append, bool):
raise ToolExecutionError('append must be a boolean')
newline_at_end = arguments.get('newline_at_end', False)
if not isinstance(newline_at_end, bool):
raise ToolExecutionError('newline_at_end must be a boolean')
if newline_at_end and content and not content.endswith('\n'):
content += '\n'
previous_text: str | None = None previous_text: str | None = None
previous_sha256: str | None = None previous_sha256: str | None = None
if target.exists() and target.is_file(): if target.exists() and target.is_file():
previous_text = target.read_text(encoding='utf-8', errors='replace') previous_text = target.read_text(encoding='utf-8', errors='replace')
previous_sha256 = hashlib.sha256(previous_text.encode('utf-8')).hexdigest() previous_sha256 = hashlib.sha256(previous_text.encode('utf-8')).hexdigest()
elif target.exists():
raise ToolExecutionError(f'Path exists and is not a file: {target}')
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding='utf-8') final_text = (previous_text or '') + content if append else content
target.write_text(final_text, encoding='utf-8')
rel = _display_path(target, context) rel = _display_path(target, context)
new_sha256 = hashlib.sha256(content.encode('utf-8')).hexdigest() new_sha256 = hashlib.sha256(final_text.encode('utf-8')).hexdigest()
action = 'append_file' if append else 'write_file'
return ( return (
f'wrote {rel} ({len(content)} chars)', f'{"appended" if append else "wrote"} {rel} ({len(content)} chars)',
{ {
'action': 'write_file', 'action': action,
'path': rel, 'path': rel,
'append': append,
'before_exists': previous_text is not None, 'before_exists': previous_text is not None,
'before_sha256': previous_sha256, 'before_sha256': previous_sha256,
'before_size': len(previous_text) if previous_text is not None else 0, 'before_size': len(previous_text) if previous_text is not None else 0,
@@ -1850,13 +1862,43 @@ def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str
else None else None
), ),
'after_sha256': new_sha256, 'after_sha256': new_sha256,
'after_size': len(content), 'after_size': len(final_text),
'after_preview': _snapshot_text(content), 'after_preview': _snapshot_text(final_text),
'content_length': len(content), 'content_length': len(content),
}, },
) )
def _coerce_write_file_content(arguments: dict[str, Any]) -> str:
provided = [
key
for key in ('content', 'content_lines', 'content_base64')
if key in arguments and arguments[key] is not None
]
if len(provided) != 1:
raise ToolExecutionError(
'Provide exactly one of content, content_lines, or content_base64'
)
source = provided[0]
if source == 'content':
content = arguments['content']
if not isinstance(content, str):
raise ToolExecutionError('content must be a string')
return content
if source == 'content_lines':
content_lines = arguments['content_lines']
if not isinstance(content_lines, list) or not all(isinstance(line, str) for line in content_lines):
raise ToolExecutionError('content_lines must be an array of strings')
return '\n'.join(content_lines)
encoded = arguments['content_base64']
if not isinstance(encoded, str):
raise ToolExecutionError('content_base64 must be a string')
try:
return base64.b64decode(encoded, validate=True).decode('utf-8')
except (ValueError, UnicodeDecodeError) as exc:
raise ToolExecutionError('content_base64 must be valid base64-encoded UTF-8 text') from exc
def _edit_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str: def _edit_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_write_allowed(context) _ensure_write_allowed(context)
target = _resolve_path(_require_string(arguments, 'path'), context, allow_missing=False) target = _resolve_path(_require_string(arguments, 'path'), context, allow_missing=False)