diff --git a/src/agent_prompting.py b/src/agent_prompting.py index f5ee0ad..dbcb525 100644 --- a/src/agent_prompting.py +++ b/src/agent_prompting.py @@ -216,7 +216,19 @@ def get_using_your_tools_section(enabled_tool_names: set[str]) -> str: if 'edit_file' in enabled_tool_names: items.append('编辑文件时,优先使用 edit_file,而不是 shell 文本替换。') 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: items.append('搜索文件时,优先使用 glob_search,而不是 find 或 ls。') 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。' ) 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( '你可以在一次响应中调用多个工具。独立工具调用尽量并行,存在依赖关系的调用保持顺序执行。' ) diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 20ec22f..43c1983 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -1614,11 +1614,10 @@ class LocalCodingAgent: f'工具:{name};位置:line {exc.lineno} column {exc.colno};' f'原因:{exc.msg};参数片段:{preview!r}。' '请回复“继续”,我会要求模型重新生成合法 JSON 参数;' - '对于 product-data 生成数据,不要用 write_file 写大段 draft;' - '应每批最多 8 条,直接用 python_exec 的 script_path 模式调用 ' - 'skills/product-data/scripts/normalize_dataset_draft.py,' - '通过 stdin 传入小批量 JSON,并把 normalize 结果追加到 ' - 'scratchpad/normalized_records.jsonl。' + '如果是 write_file 写多行文本,应改用 content_lines 或 content_base64;' + '如果 write_file 仍不稳定,应改用 python_exec 作为通用写文件兜底;' + '如果是生成/转换结构化数据,应优先调用对应 skill 脚本或 python_exec,' + '不要把大段 JSON/CSV/draft 直接塞进工具参数;bash 仅作为最后兜底。' ) from exc if not isinstance(arguments, dict): raise OpenAICompatError( diff --git a/src/agent_tool_specs/files.py b/src/agent_tool_specs/files.py index 3cbf8ad..0415045 100644 --- a/src/agent_tool_specs/files.py +++ b/src/agent_tool_specs/files.py @@ -38,14 +38,34 @@ def build_file_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentTool]: ), AgentTool( 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={ 'type': 'object', 'properties': { - 'path': {'type': 'string'}, - 'content': {'type': 'string'}, + 'path': {'type': 'string', 'description': 'Relative path. output/, scratchpad/, and input/ route to the current session.'}, + '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.', + }, + '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', 'content'], + 'required': ['path'], }, 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'), ), ] - diff --git a/src/agent_tools.py b/src/agent_tools.py index 026110f..f945bc6 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import hashlib import json import os @@ -1824,23 +1825,34 @@ def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str _ensure_write_allowed(context) target = _resolve_path(_require_string(arguments, 'path'), context) _ensure_not_platform_code_write(target, context) - content = arguments.get('content') - if not isinstance(content, str): - raise ToolExecutionError('content must be a string') + content = _coerce_write_file_content(arguments) + append = arguments.get('append', False) + 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_sha256: str | None = None if target.exists() and target.is_file(): previous_text = target.read_text(encoding='utf-8', errors='replace') 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.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) - 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 ( - f'wrote {rel} ({len(content)} chars)', + f'{"appended" if append else "wrote"} {rel} ({len(content)} chars)', { - 'action': 'write_file', + 'action': action, 'path': rel, + 'append': append, 'before_exists': previous_text is not None, 'before_sha256': previous_sha256, '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 ), 'after_sha256': new_sha256, - 'after_size': len(content), - 'after_preview': _snapshot_text(content), + 'after_size': len(final_text), + 'after_preview': _snapshot_text(final_text), '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: _ensure_write_allowed(context) target = _resolve_path(_require_string(arguments, 'path'), context, allow_missing=False)